What do Scribble API errors look like?
For developers
Every error is the same shape: { "error": { "code", "message", "docs" } }. The code is stable and safe to switch on; the message is written for a person reading a terminal and may change. A 4xx means nothing was created and nothing was charged.
On this page
{
"error": {
"code": "invalid_recipient",
"message": "recipient.line1, recipient.state, recipient.zip are required. A card cannot be written for an address we cannot address an envelope to.",
"docs": "https://scribblecards.com/docs/api",
"request_id": "req_9e2de6b00c73aff81e462b18"
}
}#Headers on every response
| Header | What it tells you |
|---|---|
x-request-id | This request, in our logs. Quote it when asking us anything. |
x-ratelimit-limit | Requests allowed per minute for this key. |
x-ratelimit-remaining | How many are left in the current minute. Pace yourself on this rather than waiting for a 429. |
x-ratelimit-reset | Unix seconds at which the window rolls over. |
idempotent-replay | Present and true when the response is a replay of an earlier identical request rather than a new card. |
retry-after | On a 429 or a 409 request_in_progress: seconds to wait. |
#Every error code
| Status | Code | Meaning | Charged? |
|---|---|---|---|
| 400 | invalid_json | The body is not valid JSON. | No |
| 400 | invalid_body | The body is valid JSON but not an object. | No |
| 400 | missing_id | No card code in the path. | No |
| 400 | invalid_cursor | starting_after does not match anything in this organization. | No |
| 400 | invalid_idempotency_key | The key is longer than 255 characters, or the replay cache could not be reached — in which case the request is refused rather than risking a duplicate card. | No |
| 401 | missing_key | No bearer token. | No |
| 401 | malformed_key | The token is not a Scribble key. | No |
| 401 | invalid_key | Unknown key. | No |
| 401 | revoked_key | The key was revoked. | No |
| 401 | invalid_hook | The inbound hook URL is not recognized. | No |
| 403 | missing_scope | The key lacks the scope this endpoint needs. | No |
| 403 | hook_disabled | The inbound hook was switched off. | No |
| 404 | card_not_found | No card with that code belongs to this organization. | No |
| 404 | design_not_found | No design with that id belongs to this organization — or, for a card: id, no card in Scribble's public collection has that slug. | No |
| 402 | no_credits | The balance is empty, or ran out between the check and the spend. Nothing was created. | No |
| 409 | request_in_progress | Another request with this Idempotency-Key is still running. Wait Retry-After seconds and retry with the same key. | No |
| 409 | too_late_to_cancel | The card has already reached a machine. The response says where it actually is. | Yes — it was charged when it was sent |
| 415 | unsupported_media_type | Send Content-Type: application/json. | No |
| 422 | invalid_recipient | A required address field is missing or malformed. | No |
| 422 | missing_message | message was empty. | No |
| 422 | message_too_long | message exceeds 500 characters. | No |
| 422 | broken_merge_field | message still contains something field-shaped — {{first_name}}, {{First Name}}, [[company]]. This endpoint takes finished text: substitute your merge fields before sending. Nothing was written. | No |
| 422 | invalid_date | arrive_on is not YYYY-MM-DD. | No |
| 422 | unknown_handwriting | The handwriting slug is not one we offer. Call GET /api/v1/handwriting. | No |
| 422 | idempotency_key_reused | This key was already used for a request with a different body. Nothing was sent. | No |
| 422 | missing_name | A contact needs a name. | No |
| 422 | invalid_birthday | birthday is not MM-DD or YYYY-MM-DD. | No |
| 422 | template_incomplete | An inbound hook's message template needs a field the payload has no value for. | No |
| 429 | rate_limited | See rate limits. | No |
| 500 | read_failed | A read failed on our side. | No |
| 500 | create_failed | A contact could not be created. | No |
| 500 | update_failed | A contact could not be updated. | No |
| 500 | order_failed | The card could not be created. Nothing was charged. | No |
| 500 | production_failed | The card was accepted but could not be prepared for production. Nothing was charged. | No |
| 500 | internal_error | Something went wrong on our side. | No |
#Retrying safely
Keys are remembered for 24 hours, scoped to your organization. Within that window the same key always returns the same answer, and the replay carries an Idempotent-Replay: true header so you can tell a fresh send from a repeat.
| Situation | Response | What happened |
|---|---|---|
| Same key, same body, first request finished | The original response, with Idempotent-Replay: true | Nothing new was created. No credit was spent. |
| Same key, same body, first request still running | 409 request_in_progress with Retry-After | Wait the suggested seconds and retry with the same key. |
| Same key, different body | 422 idempotency_key_reused | Almost always a loop that forgot to advance the key. Nothing was sent. |
| New key | A new card | This is a different send, by definition. |
| Status | Safe to retry? | Why |
|---|---|---|
| 4xx | Yes, after fixing the request | Nothing was created. |
| 429 | Yes, after Retry-After seconds | The request never reached the handler. |
402 no_credits | Yes, after topping up | Nothing was created, and the key is released so the same one works. |
| 500 of any code | Yes | The order is rolled back before the error is returned. Nothing was charged and nothing will be sent. |
| A timeout or a dropped connection | Yes, with the same key | This is exactly what the key is for. Without one, look before you retry. |
async function sendCard(event, body) {
const res = await fetch("https://scribblecards.com/api/v1/cards", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRIBBLE_KEY}`,
"Content-Type": "application/json",
// The whole guard. Same event, same key, forever — so a retry
// after a timeout returns the first card instead of making
// a second one. No bookkeeping needed on your side.
"Idempotency-Key": event.id,
},
body: JSON.stringify(body),
});
if (res.status === 409) {
// The first attempt is still in flight. Wait and use the SAME key.
const wait = Number(res.headers.get("retry-after") ?? 5);
throw new RetryAfter(wait);
}
if (res.status === 429) {
throw new RetryAfter(Number(res.headers.get("retry-after") ?? 60));
}
if (!res.ok) {
const { error } = await res.json();
// Quote request_id if you ever need to ask us what happened.
throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
return res.json(); // { id: "SC-NH4DERB", ... }
}Last checked against the product on . Something wrong or missing? Tell us.
