> ## Documentation Index
> Fetch the complete documentation index at: https://waffo.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Delivery Contract FAQ

> The exact rules Waffo applies when delivering a webhook: how long your endpoint has to answer, which response bodies count as success, where each event's callback URL comes from, why the same event can arrive twice or out of order, and what a not-paid notification looks like.

The published pages give the shape of a webhook and the retry schedule:
[Webhook overview](/docs/en/developer-docs/webhook/overview),
[Webhook retries](/docs/en/developer-docs/webhook/retry),
[Webhook event type list](/docs/en/developer-docs/webhook/event-types) and
[Handling best practices](/docs/en/developer-docs/webhook/best-practices).

**This page is the contract at the edges** — the questions that come up when a
delivery does not behave the way the happy path suggests. For inspecting one
delivery afterwards, see
[Webhook Event Log & Manual Resend](/docs/en/support/portal-webhook-log-faq); for the
event dictionary and bulk replay, see
[Webhook Event Dictionary & Batch Replay](/docs/en/support/webhook-events-and-resend-faq).

## How long does my endpoint have to respond?

**15 seconds.** That is the read timeout on the delivery call; a connection that
is not established within 3 seconds fails earlier. Exceeding either counts as a
failed delivery and the record goes back into the retry schedule.

So do not do your business work inside the request. Verify the signature,
persist the event, answer, and process asynchronously — the pattern the
[best-practices page](/docs/en/developer-docs/webhook/best-practices) recommends. A
handler that calls three of your own services before answering is the most
common reason a correctly-written integration shows up as `FAILED`.

## I return HTTP 200. Why is the delivery still counted as failed?

Because the status code is not what decides it. The decision reads your
**response body**, and a delivery counts as successful only when both of these
hold:

1. The response is a **2xx** — any 4xx or 5xx fails before the body is read.
2. The body is a **JSON object with a `message` field whose value is `success`**.

Everything else fails: a body of `failed` or `unknown` (both of which mean
"retry me"), an empty body, a different field name, an HTML error page, or a
body that cannot be parsed as JSON at all.

Three specifics that account for most of the surprises:

| Question | Answer |
| - | - |
| Is a bare string body `success` accepted? | **No.** It must be a JSON object — `{"message":"success"}`. A plain-text `success` is rejected. |
| Is the value case-sensitive? | No. `success`, `Success` and `SUCCESS` are all accepted. |
| Is the field name case-sensitive? | **Yes.** `message` must be lowercase; `Message` is not read. |
| Do extra fields break it? | No. Additional fields are ignored. |

## Does the response Content-Type have to be application/json?

In practice, yes — send `Content-Type: application/json`. The response is read as
JSON, so a body served as `text/plain` or `text/html` cannot be parsed and the
delivery is recorded as failed even when the bytes themselves say
`{"message":"success"}`. The published
[overview](/docs/en/developer-docs/webhook/overview) shows the header on the merchant
response for exactly this reason.

## If signature verification fails, should I reject the request or process it anyway?

Never process it. Verify first, and only act on the event once verification
passes — this is also an
[acceptance requirement](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria):
on a verification failure you must not update local state, and must read the real
status from the inquiry API instead.

What to *return* follows from that: answer `{"message":"failed"}`, which keeps
the record in the retry schedule, and go find out why. A signature that will not
verify is a configuration problem on one side or the other, and it will not fix
itself by being retried — so treat the retries as breathing room, not as a fix,
and reconcile through the inquiry API in the meantime.

If your signature verification fails only after you re-serialize the payload,
that is the cause: verify over the **raw request body bytes**, never over a
parsed-and-re-stringified copy. Field order, whitespace and number formatting all
change under a round trip, and the signature was computed over the original
bytes. See
[Webhook signature verification](/docs/en/developer-docs/webhook/signature-verification).

## What exactly does "8 retries" mean, and are the intervals guaranteed?

Eight attempts **including the first**, with growing gaps — the schedule is on
[Webhook retries](/docs/en/developer-docs/webhook/retry), spanning roughly 20 hours
end to end.

Two qualifications the schedule alone does not tell you:

* **The intervals are minimums, not appointments.** A retry becomes *eligible* at
  its scheduled time and is dispatched when the delivery worker next picks it up.
  Under a backlog an attempt can land later than the table suggests. Never build
  logic that assumes a retry arrives at an exact time.
* **Once the budget is spent the record is finally failed** and no further
  automatic attempt is made. The Portal shows this as a `FAILED` record with no
  next retry time.

## Why is "next retry time" sometimes empty?

Because it is only populated while an automatic retry is actually still
scheduled. The field is filled for a record that failed *and* has retry budget
left; it is empty for a delivered record and for a record whose retries are
exhausted.

That makes the pairing of the two columns the real reading, not the status alone:

| Status | Next retry | Reading |
| - | - | - |
| Failed | a timestamp | Still scheduled; leave it alone |
| Failed | empty | Budget exhausted; only a manual resend will move it |
| Successful | empty | Delivered and accepted |

## After all attempts fail, what do I do?

Read the object's current state from its own inquiry API and reconcile — the
authoritative state always lives on the object, not in the notification. The
[failure-recovery guidance](/docs/en/developer-docs/webhook/retry) lists the same
three steps: query the object, reconcile in batch periodically, and contact
support if deliveries never arrive at all.

You can also replay from the Portal once your endpoint is fixed, in single,
multi-select or by-date-range mode, up to **50 records per request**. Note that a
replay against a still-broken endpoint consumes an attempt from the same budget —
so fix first, then replay. Details in
[Webhook Event Dictionary & Batch Replay](/docs/en/support/webhook-events-and-resend-faq).

## Why is Resend greyed out on a record?

There are exactly three reasons a record cannot be resent:

| Reason | What it means |
| - | - |
| The record has no callback URL stored | Nothing to send to; resending cannot invent a destination |
| The record is still in its first delivery | An in-flight attempt is not resendable; wait for it to settle |
| The record is not yours | It does not belong to the merchant ID of your session |

Note what is **not** on that list: a record that already delivered successfully
**can** be resent. That is deliberate — a delivery Waffo recorded as accepted may
still not have been processed on your side, so replaying it is allowed.

Resend also requires the webhook-resend permission on your Portal role. Without
it the action and the row selection are not rendered at all, rather than shown
disabled — so an operator who sees no resend control at all is looking at a
permissions question, not a record-eligibility question. Ask your Portal Admin.

## Where does each event's callback URL come from?

This is the single most common cause of "no notification arrived": no URL was
resolved, so nothing was ever sent and no record was written.

| Event | URL source | Portal-configurable? |
| - | - | - |
| `PAYMENT_NOTIFICATION` | `notifyUrl` on the order-create request — **required**, up to 1024 characters | No |
| `REFUND_NOTIFICATION` | `refundNotifyUrl` on the refund request; if absent, the Refund Notify URL configured in the Portal | Yes, as a fallback |
| `CHARGEBACK_NOTIFICATION` | The Chargeback Notify URL configured in the Portal, only | Yes, only |
| `TOKENIZATION_NOTIFICATION` | `notifyUrl` on the tokenization generate request; for a card tokenized during a payment (bind for future use), the Tokenization Notify URL configured in the Portal | Yes, for payment-time tokenization |
| Subscription events | The `notifyUrl` held on the subscription, from the subscription-create request | No |
| `PAYOUT_NOTIFICATION` | `notifyUrl` on the payout request — **required** for API-created payouts | No |
| `WITHDRAW_NOTIFICATION` | `notifyUrl` on the withdrawal request — required on merchant-initiated withdrawals | No |

Two consequences worth designing around:

* **There is no global payment notification URL.** Payment notifications go only
  to the `notifyUrl` you pass on order create, and that field is mandatory
  precisely because there is no fallback. You cannot configure one address in the
  Portal and stop sending the parameter.
* **Chargeback notifications have no request-level parameter.** If the Portal
  URL is empty, those events are simply not delivered and leave no log record. An
  empty chargeback result on the webhook log page almost always means the
  Chargeback Notify URL was never configured. The same applies to tokenization
  notifications for cards tokenized during a payment; a standalone tokenization
  request without a `notifyUrl` is likewise not notified.

Configuring the three Portal-side URLs is covered in
[Portal Self-Service Security Configuration](/docs/en/support/portal-security-config-faq).

## I changed my notification URL. Where do notifications already in the retry queue go?

**To the old URL.** The callback URL is stored on each notification record when
that record is created, and every retry — and every manual resend — sends to the
stored value. Editing the Portal configuration, or passing a different
`notifyUrl` on a new order, affects only records created afterwards.

Plan a URL migration accordingly: keep the old endpoint accepting traffic until
the in-flight window has drained, rather than cutting it over the moment the new
URL is saved.

## What does the notification URL field accept? Must it be HTTPS?

Validation, at save time, accepts:

* A URL beginning with `http://` or `https://`
* ASCII characters only, and no whitespace
* A parseable address with a host — **ports, paths and query strings are all
  allowed**
* Up to 512 characters for the Refund and Chargeback Notify URLs
* An empty value, which clears the configuration

**Use HTTPS anyway.** `http://` passes validation, but the published
[configuration requirements](/docs/en/developer-docs/webhook/overview) state HTTPS
with TLS 1.2 or above, and a webhook carries payment data.

One hard constraint that is not obvious from the validation rules: **your TLS
certificate must be trusted by a public certificate authority.** The delivery
client uses the default trust store with no exception path, so a self-signed or
otherwise untrusted certificate fails the connection and every attempt fails. A
private CA is not usable here.

## Can the same event arrive more than once? What should I dedupe on?

Yes, duplicates are possible, and your handler must be idempotent — the
acceptance criteria test for exactly this. Sources of a repeat include an
internal redelivery, a manual resend, and an authorization-stage notification
followed by a final one on the same order.

There is **no event id or delivery id in the payload**. The body is
`{"eventType": ..., "result": {...}}`, and the only header of note is the
signature. So dedupe on **`eventType` plus the business identifier inside
`result`** — the payment order, refund order, subscription, payout order,
withdrawal order or token the event is about — combined with the status you are
acting on. Deduping on the identifier alone will make you drop the final status
of an order whose authorization you already recorded.

## Is the arrival order guaranteed? Could I get "failed" before "succeeded"?

No, the order is not guaranteed, and yes — you can receive notifications out of
order. Retries, queueing and network latency all reorder deliveries.

The rule that makes this safe is on the
[best-practices page](/docs/en/developer-docs/webhook/best-practices): **do not drive
your state machine from arrival order.** Treat each notification as a trigger,
and take the terminal state from the object — either from the payload's status
field with a guard against going backwards, or by calling the inquiry API after
any notification. For subscriptions this is the documented pattern: after either
a status or a period-changed notification, call subscription inquiry once and run
a single unified flow on the result.

## On an auth-then-capture order, do I get a notification at the authorized stage?

Yes, and it arrives as **`PAYMENT_NOTIFICATION` on the same order** as the final
one — not as a distinct event type. You distinguish them by the order status in
the payload, not by the event:

| `orderStatus` | Meaning |
| - | - |
| `AUTHORIZATION_REQUIRED` | Waiting for the customer to authorize |
| `PAY_IN_PROGRESS` | Payment under way |
| `AUTHED_WAITING_CAPTURE` | Authorized; awaiting your capture call |
| `CAPTURE_IN_PROGRESS` | Capture under way |
| `PAY_SUCCESS` | Terminal — paid |
| `ORDER_CLOSE` | Terminal — not paid |

This is also why deduping on the order id alone is wrong: two notifications for
one order are expected here, and only one of them is terminal. See
[Auth and capture](/docs/en/essentials/auth-capture).

## Will a notification ever carry a non-terminal status? Should I re-query?

Yes — as the table above shows, an order can be notified while still in
progress. Act on terminal statuses; for a non-terminal one, record it and wait.
If you need certainty at that moment, call the inquiry API rather than inferring.

The one status that must never make you close an order yourself is the unknown
result: on `E0001`, keep the order open, retry with identical parameters, and wait
for the inquiry result or the notification. Closing it yourself is a documented
acceptance failure — see
[Integration acceptance criteria](/docs/en/developer-docs/tools-and-references/references/acceptance-criteria).

## Do I get a notification when an order is cancelled, times out, or fails?

Yes — all three produce a `PAYMENT_NOTIFICATION`, and all three land on the same
terminal status: **`ORDER_CLOSE`**. There is no separate `CANCELLED`, `EXPIRED`
or `FAILED` status value.

To tell them apart, read the failure detail carried in the payload's
`orderFailedReason`, which contains `orderFailedCode` and
`orderFailedDescription`. So the correct handling shape is: `ORDER_CLOSE` means
"do not fulfil"; `orderFailedReason` tells you what to say to the customer and
whether retrying makes sense.

## Do I have to subscribe to events, or does everything just fire?

There is no subscription registry and no per-event opt-in. An event is delivered
whenever a callback URL resolves for it under the table above — which means your
control over what you receive is exactly your control over which URLs exist.

The practical reading for a minimal integration: pass `notifyUrl` on order create
and handle payment notifications; leave the Portal-configured URLs empty for
event families you have not built handlers for, and pick those states up from the
inquiry APIs instead. Polling works for every object you can enumerate:
chargebacks through `/api/v1/chargeback/list` and `/api/v1/chargeback/inquiry`,
token state through `/api/v1/tokenization/inquiry`. The cost is latency — and for
chargebacks the evidence deadline runs from case creation, so if you handle
disputes at all, configure the Chargeback Notify URL rather than relying on
polling.

## Can I find my payout and withdrawal notifications on the Portal webhook page?

Not through the page. The webhook log page searches a fixed set of seven event
families — payment, refund, tokenization, chargeback and the three subscription
events — and payout and withdrawal notifications are not among them, so they will
not appear however you filter.

For those, read state from the payout and withdrawal inquiry APIs. And note the
matching configuration asymmetry: neither has a Portal-configured URL, so their
notification address is whatever you passed on the request that created them.

## How do I know a notification never got delivered?

Today, by looking rather than by being told. There is no merchant-facing alert
when deliveries fail, and no merchant API for querying notification records. Your
two routes are:

1. **The Portal webhook log**, within its 90-day window — filter for failures,
   and use the next-retry column to separate "still retrying" from "finally
   failed".
2. **Reconciliation against the inquiry APIs.** Periodically query the objects
   you are expecting terminal states for and compare against your own records.
   This is the recommended safety net and the only one that also covers events
   the Portal page does not track.

Build the second one. It is what catches the case the first one cannot show you:
an event that was never created because no URL resolved.

## Do you deliver from a fixed set of IP addresses I can allowlist?

There is no published egress IP range for webhook delivery, and no static egress
address is configured — so there is nothing you can pin. Allowlisting Waffo's
callback source by IP is not something to design around.

What to do instead: authenticate the callback rather than its origin. Every
webhook carries a signature over the raw body, signed with the Waffo private key
and verifiable with the Waffo public key from **Merchant Portal → Integration**.
A verified signature is a stronger guarantee than a source-IP match, and it does
not break when infrastructure changes.

## How do I receive callbacks on my laptop during development?

Your `notifyUrl` must be reachable from the public internet — `localhost` and
private addresses cannot be reached. Use a tunnelling tool and register the
tunnel's public URL as your `notifyUrl`; the published
[overview](/docs/en/developer-docs/webhook/overview) shows the commands for two common
ones.

Two practicalities: the tunnel hostname usually changes each time you restart it,
so pass the URL per order rather than saving it in Portal configuration, and the
certificate presented must be publicly trusted — which the hosted tunnels
provide and a local self-signed server does not.

***

Sandbox delivery follows this same contract, including the retry schedule — see
[Sandbox Simulation Limits FAQ](/docs/en/support/sandbox-simulation-limits-faq) for
what the sandbox can and cannot make happen in the first place.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.