# Webhook deduplication, retries, and troubleshooting

> Choose stable delivery IDs, interpret accepted duplicates, and retry only transient webhook-ingestion failures.

Webhook senders must treat an unobserved `202` as ambiguous and retry safely.
Hodoflow deduplicates a stable delivery identity per organization, so a retry
can be acknowledged without starting the workflow twice.

## Choose one stable delivery ID

The first non-empty header in this order becomes the delivery identity:

1. `Idempotency-Key`
2. `X-GitHub-Delivery`
3. `X-Shopify-Webhook-Id`
4. `X-Twilio-Idempotency-Token`

Prefer `Idempotency-Key` for a custom sender. Generate it once for a logical
event, persist it with the outbound job, and reuse it across every retry of that
event. Do not use `X-Request-Id`: it identifies one attempt and changes on
retry.

Without one of these headers, Hodoflow hashes the trigger token and raw body in
a two-hour time bucket. Byte-identical attempts in the bucket deduplicate, but a
retry crossing the bucket boundary can run twice. The explicit header is the
reliable choice. Delivery guard rows are retained for seven days by default.

## Accepted and duplicate responses

The first successful delivery returns:

```json
{"status":"accepted","workflow_run_id":"550e8400-e29b-41d4-a716-446655440000"}
```

A recognized retry returns the same `202` status but no new run ID:

```json
{"status":"duplicate"}
```

`duplicate` means no second run was created. Keep the run ID from the first
observed accepted response. If that response was lost, reconcile using your
business identifier and workflow-run observability rather than sending a new
delivery ID.

## Retry matrix

| Status | Runtime body | Action |
|---|---|---|
| `202` | `accepted` or `duplicate` | Stop delivery retries |
| `401` | `missing_credentials` | Exchange once if expired, then retry once with the same delivery ID |
| `401` | `Unauthorized` | Fix selected-account, organization, or connector authentication |
| `403` | `Insufficient scope` | Grant `webhooks`; do not retry unchanged |
| `404` | `Trigger not found` | Confirm token and active workflow; do not retry unchanged |
| `413` | `Request body too large` | Reduce the body; a byte-identical retry cannot succeed |
| `422` | `Processing failed` or `Unsupported trigger authentication` | Fix payload or trigger configuration |
| `429` | `Rate limit exceeded` | Wait the response's `Retry-After`, then retry with the same ID |
| `503` | `Processing temporarily unavailable` | Wait `Retry-After`, back off, and retry with the same ID |

Use exponential backoff with jitter for `503`, cap the attempt count and total
elapsed time, and preserve the delivery ID. For `429`, the server's
`Retry-After` is authoritative. Never turn a permanent `401`, `403`, `404`,
`413`, or `422` into an infinite retry loop.

## Limits and checkpoints

The default maximum body is 1,048,576 bytes and the default rate is 100 requests
per trigger in a 60-second fixed window. Both are deployment-configurable.
Check status, `Retry-After`, and the response body on every attempt.

An accepted response means the run was committed and enqueued, not completed.
Read `/api/json/v1/workflow_runs/:id` using `api_read`, then follow the run state
as described in [Follow a workflow run](/guides/follow-workflow-run).

## Diagnose without exposing credentials

Record a local sender attempt ID, stable delivery ID, response status,
`x-request-id` when present, and timestamp. Redact URL path tokens and every
credential. When the trigger returns `404`, confirm the workflow remains active;
deactivation deliberately makes a real token indistinguishable from an unknown
one.

The legacy organization-qualified path still routes but is deprecated:

```text
POST /api/v1/webhooks/:org_domain/:token
```

Use the token-only path. It avoids coupling the sender to an organization-domain
rename.

## Next steps

- Verify the [webhook operation reference](/reference/webhooks/ingest).
- Add [HMAC verification](/webhook-ingestion/authentication-signatures).
- Rehearse an unobserved-`202` retry in a non-production workspace and confirm
  the second response is `duplicate`.
