Errors
Every failure generated by Joinotify uses the same envelope:
{
"error": {
"message": "A human-readable sentence about what happened.",
"type": "stable_code"
}
}
Program against type — it is stable. message is written for humans and its
wording can change without notice.
Optional fields
A few fields show up when they apply.
issues — validation failures
{
"error": {
"message": "Invalid request payload.",
"type": "invalid_request",
"issues": [
{
"code": "too_small",
"path": ["body"],
"message": "String must contain at least 1 character(s)"
}
]
}
}
meta — an error relayed from Meta
When the refusal comes from Meta, we return their original body untouched. It carries the
code, error_subcode and fbtrace_id that Meta support asks for.
{
"error": {
"message": "The Meta Cloud API rejected the request.",
"type": "meta_error",
"meta": {
"error": {
"message": "(#131047) Re-engagement message",
"type": "OAuthException",
"code": 131047,
"error_subcode": 2494010,
"fbtrace_id": "AbCdEfGhIjK"
}
}
}
}
reason and action — no entitlement
A 402 says why access was blocked and what the way out is: what to do
(action.kind) and the dashboard page where it is done (action.url). Show both to
whoever runs the integration — in an error log, "payment required" without a link is a
dead end.
{
"error": {
"message": "Your free trial has ended. Subscribe to keep using the API.",
"type": "payment_required",
"reason": "trial_ended",
"billable_numbers": 1,
"action": {
"kind": "subscribe",
"url": "https://app.joinotify.com/checkout"
}
}
}
action.kind | When |
|---|---|
subscribe | Trial ended, subscription cancelled or none at all |
update_payment_method | The last payment failed (past_due) |
pay_invoices | There are unpaid invoices (unpaid) |
finish_checkout | The first payment has not been confirmed yet (incomplete) |
resume_subscription | The subscription is paused (paused) |
renew_license | The license has expired (license_expired) |
upgrade_plan | The plan's monthly message cap was reached (message_limit) |
manage_numbers | More numbers are connected than the account pays for (capacity_exceeded) |
The block does not arrive unannounced. During the grace period requests keep going
through, and the response carries the X-Joinotify-Billing-Warning header with the
reason — past_due, for instance. Log that header: it is what tells you a 402 is on
its way.
allowed — method not accepted
A path that exists, called with the wrong method, answers 405 rather than 404. The
accepted methods come in the Allow header and in error.allowed, so you look for the
mistake in the verb and not in the URL.
{
"error": {
"message": "PUT is not supported on this route. Allowed: DELETE, GET, HEAD.",
"type": "method_not_allowed",
"allowed": ["DELETE", "GET", "HEAD"]
}
}
request_id — unexpected failure
A 500 doesn't say what broke — the cause stays in our logs, never in the response. What
it carries is request_id, the same value as the x-request-id header. Send that id to
support and we'll find the failure.
{
"error": {
"message": "Internal server error.",
"type": "internal",
"request_id": "0d72872e-f70e-4c25-95c6-6bec4d33a800"
}
}
Code table
| HTTP | type | Meaning | What to do |
|---|---|---|---|
400 | invalid_request | No resource ID after /v1/. | Fix the path. |
400 | invalid_request | The body isn't valid JSON, or the path has a malformed % sequence. | Fix the body or the URL encoding. |
401 | authentication | Token missing, malformed, unknown or revoked. | Check the header and the key. |
402 | payment_required | No entitlement: trial ended, payment pending, plan cap reached… | Follow error.action. |
403 | tenant_inactive | Account suspended or cancelled. | Contact support. |
403 | forbidden | The resource does not belong to your account. | Confirm the phone_number_id / waba_id. |
403 | forbidden_endpoint | Path on the proxy's block list. | See Meta compatibility. |
404 | not_found | Route, key, endpoint or delivery does not exist. | Check the ID. |
405 | method_not_allowed | The path exists, but not with that method. | Use one of the methods in Allow. |
409 | no_phone_number | No number connected and from omitted. | Connect a number or pass from. |
409 | no_waba | No business account connected and wabaId omitted. | Connect a WABA or pass wabaId. |
409 | idempotency_in_progress | A request with the same Idempotency-Key is still running. | Wait and repeat with the same key. |
422 | invalid_request | Payload failed validation. | Read issues. |
413 | payload_too_large | The body is larger than the route accepts. | Split the batch or shrink the body. |
415 | unsupported_media_type | The body's charset or Content-Encoding isn't accepted. | Send JSON in UTF-8, uncompressed or with gzip. |
422 | meta_error | Meta refused (4xx). | Read error.meta. |
422 | idempotency_key_reused | The Idempotency-Key was already used with another body. | Use a new key per operation. |
429 | rate_limit | Category limit exceeded. | Wait for Retry-After. See Rate limits. |
500 | internal | Unexpected failure on our side. | Retry; if it persists, send us the request_id. |
502 | meta_error | Meta answered with a 5xx. | Retry with backoff. |
502 | upstream_error | We could not reach Meta. | Retry with backoff. |
504 | upstream_error | Meta took longer than 30 seconds. | Retry with backoff. |
Our error or Meta's?
One simple rule: if the error.meta field is there, the refusal came from Meta.
Joinotify only relayed it.
That matters for what you do next. invalid_request means your payload is wrong and an
identical retry will fail again. meta_error is usually a WhatsApp business rule — the
24-hour window closed, an unapproved template, an invalid number — and the fix is
different.
On the /v1/* mirror the rule changes: when Meta responds, you get their response, in
their format, without our envelope. The Joinotify envelope only appears for failures that
happen before the call.
Tracing a request
Every response carries x-request-id. If you send your own x-request-id, we echo it —
which makes it easy to line up logs on both sides.
Your own id is only accepted if it has 8 to 128 characters, from letters, digits, ., _,
: and -. Otherwise we generate one of ours (a UUID) — always trust the one that comes
back in the header.
curl -i https://api.joinotify.com/templates \
-H 'Authorization: Bearer sk_live_xxx' \
-H 'x-request-id: order-1042-attempt-1'
Keep that identifier in your logs. It's the first thing support asks for.
Which errors are worth retrying
| Situation | Retry? |
|---|---|
429 rate_limit | Yes, after Retry-After. |
500 · 502 · 504 | Yes, with exponential backoff and a cap on attempts. |
409 idempotency_in_progress | Yes, after a few seconds, with the same key. |
402 payment_required | No. Sort out what error.action points to. |
401 · 403 · 404 · 405 · other 409s | No. A retry fails the same way. |
400 · 413 · 415 · 422 invalid_request | No. Fix the payload. |
422 meta_error | Depends on Meta's code. A window or template error won't fix itself. |