Dead-letter recovery
Inspect the messages that exhausted their retries on your tenant, replay or discard them, revive stuck outbox records and schedules, and redispatch archived events.
Tanitar delivers messages between its services durably: each one is written to an outbox in the same transaction as the change that caused it, published, and retried on failure. A message that a consumer still cannot process after its retries is set aside as a dead letter, together with the reason. Your integration or an administrator can inspect your tenant's dead letters and decide what to do with each one.
Recovery only ever reaches your own tenant: the account comes from your credential, and a record
that belongs to another tenant answers 404.
Where the routes live
The recovery routes are mounted by the Tournaments API and the Gamification API, each for
the messages its own service consumes. They sit under /platform/recovery, with no /v1 prefix,
on each API's base URL ($TOURNAMENT_API_URL or $GAMIFICATION_API_URL).
| Route | Scope | Rate class |
|---|---|---|
GET /platform/recovery/dead-letters | messaging:read | read |
GET /platform/recovery/dead-letters/:deadLetterId | messaging:read | read |
POST /platform/recovery/dead-letters/:deadLetterId/replay | messaging:replay | mutation |
POST /platform/recovery/dead-letters/:deadLetterId/discard | messaging:replay | mutation |
POST /platform/recovery/outbox/:outboxId/revive | messaging:replay | mutation |
POST /platform/recovery/schedules/:scheduleId/revive | messaging:replay | mutation |
POST /platform/recovery/archive/redispatch | messaging:replay | mutation |
Every POST requires an Idempotency-Key header. Give messaging:replay only to the keys that
should be able to re-run work; messaging:read alone lets a key look without acting.
List dead letters
GET /platform/recovery/dead-letters returns your dead letters, newest failure first, 50 per page.
| Query | Meaning |
|---|---|
status | Optional filter: pending (awaiting a decision), replayed or discarded. |
skip | The nextCursor of the previous page. |
The answer is { "data": { "items": [...], "nextCursor": "50" } }; pass nextCursor back as skip
until it is null.
curl "$TOURNAMENT_API_URL/platform/recovery/dead-letters?status=pending" \
-H "Authorization: Bearer $TANITAR_API_KEY"Each item describes the failure, never the message itself:
| Field | Meaning |
|---|---|
deadLetterId | The record to act on. |
consumer | The consumer that gave up on the message. |
messageId, originalRoutingKey | Which message it was and where it was going. |
errorCode, failureClassification, lastError | Why it failed (retryable or permanent), with the error text sanitized. |
attempt, failedAt | How many deliveries were tried and when the last one failed. |
status, replayCount, lastReplayedAt, resolvedBy, resolvedAt | What has been done about it. |
The message body is never returned. GET /platform/recovery/dead-letters/:deadLetterId returns the
same projection for one record. Dead letters are kept for 30 days.
Replay a dead letter
POST /platform/recovery/dead-letters/:deadLetterId/replay puts the original message back on its
original route as a fresh delivery attempt, once the cause is fixed.
curl -X POST "$TOURNAMENT_API_URL/platform/recovery/dead-letters/$DEAD_LETTER_ID/replay" \
-H "Authorization: Bearer $TANITAR_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"The answer is a receipt:
{
"data": {
"replayId": "rpl_0190f1c2-7a3b-7c4d-8e5f-6a7b8c9d0e1f",
"deadLetterId": "dlr_0190f1c2-0000-7000-8000-000000000001",
"messageId": "4b8f6c1e-2d3a-4e5f-8a9b-0c1d2e3f4a5b",
"deliveryAttemptId": "dly_0190f1c2-0000-7000-8000-000000000002",
"requestedBy": "key_example",
"requestedAt": "2027-03-10T20:15:00.000Z",
"idempotencyKey": "5f0c2b8e-1a7d-4c3e-9b6f-2d8a0e4c7f19",
"selectionCount": 1,
"outcome": "replayed"
}
}How replay behaves:
- It goes through the outbox. The replay is written to the outbox in the same transaction that
marks the record
replayed, so it is published exactly like any other message, with the headersx-replay-ofandx-replay-requested-byadded. - The consumer stays idempotent. The message keeps its original
messageId. A consumer that had in fact already processed it recognizes it and does not apply its effect twice. - The same key returns the same receipt. Retrying a replay with the same
Idempotency-Keyreturns the first receipt and publishes nothing new. A new key replays again. - Replays are bounded. One record can be replayed at most 5 times; after that the call answers
409RECOVERY_REPLAY_BUDGET_EXHAUSTED. - Some records cannot be replayed. A message that could not be read at all has no stored
message to resend and answers
422RECOVERY_ENVELOPE_UNREPLAYABLE; discard it instead. - Every decision is attributed.
resolvedByandrequestedByrecord the credential that acted.
If the replayed message fails again, it becomes a new dead letter.
Discard a dead letter
POST /platform/recovery/dead-letters/:deadLetterId/discard marks a record discarded when you
decide the message should not be processed. A discarded record cannot be replayed or discarded again
(409 RECOVERY_STATE_CONFLICT). The same Idempotency-Key returns the same receipt, with
outcome discarded.
Revive an outbox record or a schedule
Two other kinds of work can fail before any consumer sees them, either after exhausting retries or after an error that cannot succeed on retry:
- an outbox record that could not be published, and
- a scheduled job (for example a tournament's automatic registration close or a match forfeit) that kept failing.
Both end in a dead state. When you have its identifier from support or your own logs, revive it:
POST /platform/recovery/outbox/:outboxId/revivereturns the outbox record topendingwith its attempts reset, so it is published again;POST /platform/recovery/schedules/:scheduleId/revivereturns the schedule topending, due now.
The answer is a receipt with outcome revived. A record that is not dead answers 409
RECOVERY_STATE_CONFLICT; an unknown one answers 404 RECOVERY_RECORD_NOT_FOUND.
Redispatch archived events
Every event that was published and confirmed is archived. If a downstream system lost what it
received in a time window, POST /platform/recovery/archive/redispatch publishes those events again
from the archive:
| Field | Meaning |
|---|---|
windowStartMs | Start of the window, Unix time in milliseconds. |
windowEndMs | End of the window, inclusive. At most 30 days after the start. |
curl -X POST "$GAMIFICATION_API_URL/platform/recovery/archive/redispatch" \
-H "Authorization: Bearer $TANITAR_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"windowStartMs":1804406400000,"windowEndMs":1804410000000}'A window may select at most 50 events; a wider one is refused with 400
RECOVERY_SELECTION_TOO_LARGE, so narrow it and call again for each part. An inverted or too-long
window answers 400 RECOVERY_WINDOW_INVALID. The receipt has outcome reconstructed and a
selectionCount; the same Idempotency-Key returns it again without republishing. Redispatched
events keep their messageId, so idempotent consumers ignore what they already have. This includes
a webhook receiver that deduplicates on webhook-id.
Errors
errorCode | Status | Meaning |
|---|---|---|
RECOVERY_RECORD_NOT_FOUND | 404 | No such record in your tenant. |
RECOVERY_STATE_CONFLICT | 409 | The record is discarded, not dead, or changed concurrently. |
RECOVERY_REPLAY_BUDGET_EXHAUSTED | 409 | The record has been replayed 5 times. |
RECOVERY_ENVELOPE_UNREPLAYABLE | 422 | There is no readable message to replay. |
RECOVERY_SELECTION_TOO_LARGE | 400 | The archive window selects more than 50 events. |
RECOVERY_WINDOW_INVALID | 400 | The archive window is missing, inverted or longer than 30 days. |
All of them use the shared error envelope.
Failed deliveries to your own webhook endpoint are a separate mechanism, handled by the webhook retry ladder and webhook replay. See Webhook replay. For how messages move between services, see Delivery guarantees.