Verify a webhook

Prove a delivery came from us, including while a secret is rotating.

We POST events to a URL you own. Anybody can POST to that URL, so every delivery is signed.

POST /hooks/inversepay HTTP/1.1
Content-Type: application/json
X-InversePay-Signature: t=1755624000,v1=<hex>
X-InversePay-Event: payout.completed
X-InversePay-Delivery: 8f14e45f-ceea-467a-9c4b-1f0bd4f1a2c3

X-InversePay-Delivery is the attempt. Two deliveries of the same event carry different delivery ids, which is what makes "have I already handled this?" a question you can answer.

What we send

Payout transitions, each fired exactly once per payout:

Event
payout.createdRaised, funds reserved.
payout.parkedHeld by a compliance control.
payout.releasedA hold was cleared.
payout.approvedSigned.
payout.processingWith a rail.
payout.completedPaid.
payout.failedTerminal. The reservation has been released.
payout.returnedPaid, then sent back by the rail.
payout.reroutedMoved to another partner after the first could not take it. Slower, not broken.
payout.reconcilingA rail stopped answering mid-flight and we do not yet know whether the money moved.

payout.reconciling is deliberately not payout.failed. Telling a business its payment failed when it may have succeeded is the one wrong answer here — treat it as "unknown", never as "no".

Charge transitions use the same naming: charge.created, charge.succeeded, charge.failed, charge.refunded, charge.partially_refunded, charge.disputed.

Register an endpoint

curl -X POST https://sandbox.inversepay.app/api/b/v1/webhooks \
  -H "Authorization: Bearer $INVERSEPAY_TEST_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/inversepay",
    "description": "Production receiver",
    "event_types": ["charge.succeeded", "payout.failed"]
  }'

The secret is returned once. A platform that can re-read your signing secret is one an attacker reads it out of. Store it when you see it.

Omit event_types to receive everything.

Verify a delivery

Four rules, and the third is the one people get wrong.

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(header: str, body: bytes, secrets: list[str]) -> bool:
    """`body` must be the raw bytes of the request, not a re-serialised object."""
    parts = dict(
        piece.split("=", 1) for piece in header.split(",") if "=" in piece
    )
    timestamp = parts.get("t")
    if timestamp is None:
        return False

    # 2. Reject anything too old, or the first person to capture a delivery can
    #    replay it forever.
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    # 3. A header may carry MORE THAN ONE v1. Collect them all.
    signatures = [
        value for piece in header.split(",")
        if piece.startswith("v1=") for value in [piece[3:]]
    ]

    signed = f"{int(timestamp)}.".encode() + body

    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        for candidate in signatures:
            # 4. Constant time. A compare that returns early leaks, over enough
            #    attempts, how much of a guess was right.
            if hmac.compare_digest(expected, candidate):
                return True
    return False

1. Sign the raw body

Two JSON documents can mean the same thing and serialise differently — key order, whitespace, how a float renders. A receiver that re-serialises before checking will eventually reject a message we really sent. The bytes are the message.

Most frameworks make this the awkward part. In Express you need express.raw({ type: 'application/json' }); in FastAPI, await request.body() before anything parses it.

2. Check the timestamp

The timestamp is inside the signed material, so it cannot be edited. Reject deliveries older than five minutes.

3. Accept any matching v1= — this is the one that bites

A verifier that reads only the first v1= will reject roughly half of every delivery during a secret rotation, and will look like an outage on our side.

When you rotate a secret, both the old and the new one sign the same body for the overlap window. That is what lets you deploy a new secret without a synchronised restart: a receiver that has the new secret and one that still has the old one both verify.

So collect every v1= in the header and accept the message if any of them matches any secret you hold.

4. Compare in constant time

hmac.compare_digest, not ==.

Retries

A delivery we cannot complete is retried with exponential backoff — 30 seconds times three to the power of the attempt, capped at six hours, with jitter. Return 2xx quickly and do the work afterwards; a slow handler looks like a failure and earns you a duplicate.

Events can arrive more than once. Make your handler idempotent on the event id. This is the same discipline as Idempotency-Key on the way out, in the other direction.

Deliveries that exhaust their retries land in a dead-letter queue you can read and replay:

curl https://sandbox.inversepay.app/api/b/v1/webhooks/deliveries/dead-letter \
  -H "Authorization: Bearer $INVERSEPAY_TEST_KEY"

Rotating a secret

Rotate, deploy the new secret, then revoke the old one once your last instance has it. During the overlap both signatures are sent — which only works if your verifier follows rule 3.

On this page