Risk Operations / 4 min read

Webhook Retries and Idempotency for Risk Decisions

Accept the webhook quickly, persist the ids, and make every case update safe to retry so a second delivery does not open a second case.

By Naiza

11 Jul 2026 · Updated 5 Oct 2026

The fraud decision is not finished when your API call returns. Someone still has to open a case, leave a note, maybe hold a payout, and later send a label back. Webhooks are how that work arrives. They also arrive twice. If your handler is not idempotent, the second delivery is a duplicate case, a reopened ticket, or a customer who gets two “we are reviewing your payment” emails.

I treat the webhook endpoint as a thin intake, not as the place where cases are born.

Receive, store, return

Verify the signature. Persist the payload and the ids you will use as keys: eventType plus data.id (the documented idempotency pair), the event or decision id inside the payload, and your own case key if you already have one. Enqueue the work. Return success once the message is safe on your side. Do not create the case, notify the customer, or call three internal services inside the HTTP request. Those steps time out. Timeouts cause retries. Retries cause the mess you are trying to avoid.

POST /webhooks/naiza/risk

If you need a walkthrough of the feedback side, start with the webhooks reference. The pattern is the same for fraud and for AML writes: accept quickly, process once.

Long work belongs on your queue. The queue can retry with backoff. The HTTP handler should be boring.

One event, one case

Use the Naiza event identifier plus your internal case identifier as the stable key. Upsert the case by event id before you add comments or tasks. If the same webhook shows up again, the second attempt updates the row you already have. It does not insert a sibling.

Record the last applied decision. Repeated REVIEW or BLOCK messages should not reopen a case a reviewer already closed as genuine activity. Repeated ALLOW should not cancel a hold you applied for a different reason. Customer-facing state changes sit behind one explicit transition in your product: pending review, paid, declined. The webhook is an input to that transition, not a second state machine that races the first.

I also store the screening id when the webhook is AML-related. Fraud uses ALLOW, REVIEW, and BLOCK. AML screening uses AUTO_CLEAR, REVIEW, and CONFIRMED. Do not let a shared case table collapse those vocabularies into a single “risky” flag. The reviewer needs to know whether they are looking at a velocity rule or a possible list match.

Retries that a human can debug

Retries are useful only if the on-call person can see what failed. Log the response code, destination, event id, and next retry time. When the same destination fails repeatedly, send one alert with the affected event ids, not one page per attempt. I have watched a misconfigured URL page a team forty times and still leave them without a single id they could search.

Do not retry 4xx from your handler into Naiza as if it were a new subscription problem. Fix the signature, the URL, or the payload parsing. Retry 5xx and timeouts on the delivery side with jitter. On your queue, retry the processing job, not the HTTP receipt.

If you ingest some events asynchronously, the webhook is often the first time the product hears a REVIEW. That is why this post sits next to sync versus async event ingestion. Sync paths already have a decision in the request thread. Async paths depend on this delivery story being safe.

Close the loop on purpose

When an investigation finishes, send feedback with the final label and your case reference. Do it once. Key the feedback write on the event id and case id so a double-click in the case tool does not create two labels. That trail is what you will want when someone asks why a rule was tightened.

I do not auto-send feedback from the webhook handler. The webhook tells you Naiza’s decision. Feedback tells Naiza your outcome. Mixing them in one request is how you label a case “fraud” because the first hop was REVIEW.

The test I run in staging is dull and sufficient: deliver the same signed payload twice, crash the worker after the first upsert, then let the retry finish. I should see one case, one customer email, one stored decision. If I see two of anything, the key is wrong.

Store the identifiers, create or update the case before adding other records, and apply customer-facing state changes through one workflow.