AUTH4D-307 Resending a failed webhook delivery

A webhook delivery (AUTH4D-157) that used up its six attempts, or was ended early with endpoint_disabled, endpoint_deleted or invalid_url, used to stay failed for good. Owners and admins can now put a failed delivery back in the queue from the console delivery log or the management API. The delivery log keeps each row, with its payload and event id, for 30 days, so any failed delivery still in the log can be resent.

Route

Method and path Scope / role Result
POST /management/tenants/{tenantId}/webhooks/{endpointId}/deliveries/{deliveryId}/redeliver tenant:write, owner or admin, {} 200 { delivery }, no-store

The body must be an empty JSON object (400 invalid_request otherwise), like the other webhook action routes. The response is the existing WebhookDeliveryResponseSchema, so no contract change was needed:

{
  "delivery": {
    "deliveryId": "whd_…",
    "endpointId": "whe_…",
    "eventId": "evt_…",
    "eventType": "user.deleted",
    "status": "pending",
    "attempts": 0,
    "lastResponseStatus": 503,
    "lastError": null,
    "nextAttemptAt": null,
    "createdAt": "2026-10-21T08:00:00.000Z",
    "updatedAt": "2026-10-22T09:15:00.000Z"
  }
}

lastResponseStatus keeps the status of the last failed attempt until the next attempt replaces it.

Rules

  • One conditional UPDATE filtered by tenant_id, endpoint_id, delivery_id and status = 'failed' sets the row back to pending with attempts = 0 and clears next_attempt_at, lease_until and last_error. Only when it changes a row is one WEBHOOK_DELIVERIES message sent. Two resends at once give one 200 and one 409, and one message.
  • The resent request keeps the stored webhook-id (the event id) and body, so receivers that deduplicate on webhook-id handle it once. It is signed when it is attempted, with the endpoint’s current secret, plus the previous one during a rotation overlap (AUTH4D-279).
  • The resend gets the normal six attempts and backoff.

Errors

Status Code When
400 invalid_request The body is not an empty JSON object.
401 unauthorized No valid management token, or the token lacks tenant:write.
403 forbidden The caller is not a member of the project.
403 insufficient_role The caller is a viewer. The refusal is audited with outcome denied.
404 not_found The endpoint, or the delivery under that endpoint, does not exist in the project.
409 webhook_disabled The endpoint is turned off, so the resend would fail again at once.
409 delivery_not_failed The delivery is pending, delivering or succeeded.
409 project_suspended The project is suspended.
503 temporarily_unavailable The WEBHOOK_DELIVERIES queue is not bound. The row stays failed.

A delivery of another project, or of another endpoint in the same project, is 404, so the caller cannot tell whether it exists.

Audit event

Event Target type Metadata
management.webhook.delivery_redelivered webhook_endpoint actor_role, delivery_id

The audit row is written in the same D1 batch as the update, guarded by the same status = 'failed' condition, so it is written exactly when a resend happens. Management events are checked by AuditEventSchema (AUTH4D-228) and delivery_id was already used by management.webhook.test_sent, so the observability SAFE_METADATA allowlist needs no new keys.

Console

The delivery log shows Resend under the status of failed rows, for owners and admins only. The button’s accessible name includes the event and time. After a resend the row shows Queued, a status message says the delivery was queued again, and focus moves to that message. A refusal shows fixed wording (for example “Enable the endpoint before resending a delivery.”) and never the server’s message. The table’s scroll area can now be focused, so it scrolls by keyboard at 320px. The console BFF forwards the new path.

Tests

  • tests/integration/auth4d-307.webhook-redeliver.test.ts: a delivery driven to failed by six 503 answers is resent after a secret rotation, and the next attempt carries the same webhook-id and body, verifies with the new secret only and leaves the row succeeded; a delivery dropped with endpoint_disabled is refused while the endpoint is off and resent once it is on; 409 for pending, delivering and succeeded rows; one 200 and one 409 for two resends at once; 404 across projects and endpoints; scope and role checks; the empty body; and 503 without the queue.
  • tests/e2e/auth4d-307.webhook-redeliver.spec.ts (auth4d-307.playwright.config.ts, port 5461): Resend only on failed rows, a keyboard resend that turns the row to Queued, with axe checks at 320px and 1280px; refusals in plain wording; and a viewer without the action.

Source: docs/api-spec/auth4d-307.md