TANITARDevelopers
Guides

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).

RouteScopeRate class
GET /platform/recovery/dead-lettersmessaging:readread
GET /platform/recovery/dead-letters/:deadLetterIdmessaging:readread
POST /platform/recovery/dead-letters/:deadLetterId/replaymessaging:replaymutation
POST /platform/recovery/dead-letters/:deadLetterId/discardmessaging:replaymutation
POST /platform/recovery/outbox/:outboxId/revivemessaging:replaymutation
POST /platform/recovery/schedules/:scheduleId/revivemessaging:replaymutation
POST /platform/recovery/archive/redispatchmessaging:replaymutation

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.

QueryMeaning
statusOptional filter: pending (awaiting a decision), replayed or discarded.
skipThe 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:

FieldMeaning
deadLetterIdThe record to act on.
consumerThe consumer that gave up on the message.
messageId, originalRoutingKeyWhich message it was and where it was going.
errorCode, failureClassification, lastErrorWhy it failed (retryable or permanent), with the error text sanitized.
attempt, failedAtHow many deliveries were tried and when the last one failed.
status, replayCount, lastReplayedAt, resolvedBy, resolvedAtWhat 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 headers x-replay-of and x-replay-requested-by added.
  • 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-Key returns 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 409 RECOVERY_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 422 RECOVERY_ENVELOPE_UNREPLAYABLE; discard it instead.
  • Every decision is attributed. resolvedBy and requestedBy record 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/revive returns the outbox record to pending with its attempts reset, so it is published again;
  • POST /platform/recovery/schedules/:scheduleId/revive returns the schedule to pending, 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:

FieldMeaning
windowStartMsStart of the window, Unix time in milliseconds.
windowEndMsEnd 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

errorCodeStatusMeaning
RECOVERY_RECORD_NOT_FOUND404No such record in your tenant.
RECOVERY_STATE_CONFLICT409The record is discarded, not dead, or changed concurrently.
RECOVERY_REPLAY_BUDGET_EXHAUSTED409The record has been replayed 5 times.
RECOVERY_ENVELOPE_UNREPLAYABLE422There is no readable message to replay.
RECOVERY_SELECTION_TOO_LARGE400The archive window selects more than 50 events.
RECOVERY_WINDOW_INVALID400The 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.

On this page