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

# Webhook Delivery

> Receive, verify, deduplicate, and process signed commerce events.

Create and manage webhook endpoints under **Business profile → Developers → Webhooks**.

## Verification key

Fetch Anyway's public Ed25519 key and cache it:

```bash theme={null}
curl https://api.anyway.sh/v1/webhooks/signing-key
```

```json theme={null}
{
  "keyId": "default",
  "algorithm": "ed25519",
  "publicKey": "whpk_<base64 public key>",
  "keys": [{
    "kty": "OKP",
    "use": "sig",
    "crv": "Ed25519",
    "kid": "default",
    "x": "<base64url public key>",
    "alg": "EdDSA"
  }]
}
```

`publicKey` is the prefixed raw-key representation, while `keys` is the equivalent JWKS representation. The delivery envelope follows Standard Webhooks and uses an asymmetric Ed25519 (`v1a`) signature. Refresh the cached key if verification begins failing after a key rotation.

## Events

Current events are:

| Event                  | Meaning                                                      |
| ---------------------- | ------------------------------------------------------------ |
| `order.pending`        | An order exists but payment is not confirmed                 |
| `order.paid`           | Payment is confirmed and the order may be fulfilled          |
| `order.failed`         | The payment attempt failed                                   |
| `subscription.created` | A customer subscription was created                          |
| `subscription.updated` | The current period or scheduled-cancellation details changed |
| `subscription.expired` | The subscription expired                                     |
| `subscription.ended`   | The subscription reached its final ended state               |

An empty endpoint event selection subscribes to all supported events. Fulfill only after a
verified `order.paid`. A period-end cancellation is reported through
`subscription.updated`; use `cancelAtPeriodEnd` and `currentPeriodEnd` to represent the
scheduled state before `subscription.ended`.

## Delivery headers and signed content

| Header              | Meaning                                                                             |
| ------------------- | ----------------------------------------------------------------------------------- |
| `webhook-id`        | Stable logical event ID and idempotency key; unchanged across retries of that event |
| `webhook-timestamp` | Unix timestamp in seconds                                                           |
| `webhook-signature` | `v1a,<base64 Ed25519 signature>` in Standard Webhooks format                        |

The signed message is the exact byte sequence:

```text theme={null}
webhook-id.webhook-timestamp.raw-body
```

Verify against the raw request body before JSON parsing. Reject stale timestamps and unknown signatures.

## Verify Ed25519 signatures

<CodeGroup>
  ```javascript Node.js theme={null}
  import express from "express";
  import { createPublicKey, verify as verifySignature } from "node:crypto";

  const signingKeyResponse = await fetch("https://api.anyway.sh/v1/webhooks/signing-key");
  if (!signingKeyResponse.ok) throw new Error("Unable to load Anyway signing keys");
  const jwks = await signingKeyResponse.json();
  const verificationKeys = jwks.keys.map((jwk) =>
    createPublicKey({ key: jwk, format: "jwk" }),
  );

  function verifyAnywayWebhook(rawBody, headers) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatureHeader = headers["webhook-signature"];
    if (!id || !timestamp || !signatureHeader) throw new Error("Missing webhook headers");

    const timestampSeconds = Number(timestamp);
    if (!Number.isSafeInteger(timestampSeconds) ||
        Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > 300) {
      throw new Error("Stale webhook timestamp");
    }

    const signedContent = Buffer.concat([
      Buffer.from(`${id}.${timestamp}.`, "utf8"),
      rawBody,
    ]);
    const valid = signatureHeader.split(" ").some((versionedSignature) => {
      const [version, encodedSignature] = versionedSignature.split(",", 2);
      if (version !== "v1a" || !encodedSignature) return false;
      try {
        const signature = Buffer.from(encodedSignature, "base64");
        return verificationKeys.some((key) =>
          verifySignature(null, signedContent, key, signature),
        );
      } catch {
        return false;
      }
    });
    if (!valid) throw new Error("Invalid webhook signature");
    return JSON.parse(rawBody.toString("utf8"));
  }

  const app = express();

  app.post("/webhooks/anyway", express.raw({ type: "application/json" }), (req, res) => {
    let event;
    try {
      event = verifyAnywayWebhook(req.body, {
        "webhook-id": req.header("webhook-id"),
        "webhook-timestamp": req.header("webhook-timestamp"),
        "webhook-signature": req.header("webhook-signature"),
      });
    } catch {
      return res.sendStatus(401);
    }

    enqueueIdempotently(req.header("webhook-id"), event);
    return res.sendStatus(204);
  });
  ```

  ```python Python theme={null}
  # pip install cryptography
  import base64
  import json
  import time
  from urllib.request import urlopen

  from cryptography.exceptions import InvalidSignature
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

  def decode_base64url(value):
      return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))

  with urlopen("https://api.anyway.sh/v1/webhooks/signing-key", timeout=5) as response:
      jwks = json.load(response)

  verification_keys = [
      Ed25519PublicKey.from_public_bytes(decode_base64url(jwk["x"]))
      for jwk in jwks["keys"]
  ]

  def verify_anyway_webhook(raw_body, headers):
      webhook_id = headers.get("webhook-id")
      timestamp = headers.get("webhook-timestamp")
      signature_header = headers.get("webhook-signature")
      if not webhook_id or not timestamp or not signature_header:
          raise ValueError("Missing webhook headers")

      timestamp_seconds = int(timestamp)
      if abs(int(time.time()) - timestamp_seconds) > 300:
          raise ValueError("Stale webhook timestamp")

      signed_content = f"{webhook_id}.{timestamp}.".encode() + raw_body
      for versioned_signature in signature_header.split():
          try:
              version, encoded_signature = versioned_signature.split(",", 1)
              if version != "v1a":
                  continue
              signature = base64.b64decode(encoded_signature, validate=True)
              for key in verification_keys:
                  try:
                      key.verify(signature, signed_content)
                      return json.loads(raw_body)
                  except InvalidSignature:
                      pass
          except (ValueError, TypeError):
              continue
      raise InvalidSignature("Invalid webhook signature")


  @app.post("/webhooks/anyway")
  def anyway_webhook():
      try:
          event = verify_anyway_webhook(request.get_data(), request.headers)
      except (InvalidSignature, ValueError):
          return ("", 401)

      enqueue_idempotently(request.headers["webhook-id"], event)
      return ("", 204)
  ```
</CodeGroup>

## Payload

The signed envelope contains `type`, RFC 3339 `timestamp`, `apiVersion`, endpoint identity,
and either `data.order` or `data.subscription`, according to the event type.

Important order fields include:

| Field                                   | Meaning                                                                  |
| --------------------------------------- | ------------------------------------------------------------------------ |
| `orderId`, `orgId`                      | Anyway order and organization                                            |
| `merchantReference`, `merchantMetadata` | Your correlation values; treat as untrusted strings                      |
| `status`, `provider`                    | Canonical payment state and rail                                         |
| `amountCents`, `currency`               | Minor-unit amount and lowercase settlement currency                      |
| `product`                               | Optional product `id` and `name`                                         |
| `paymentLinkId`                         | Originating link when known                                              |
| `crypto`                                | Optional CAIP chain, asset, payer, recipient, and transaction provenance |
| `originalOrderId`                       | Related original order when applicable                                   |
| `providerSubscriptionId`                | Payment-channel subscription attribution when applicable                 |
| `createdAt`, `updatedAt`                | RFC 3339 UTC timestamps                                                  |

For a payment link opened with `merchant_reference=PUR_456&user_id=USR_123&source=web`,
an order event contains the same values:

```json theme={null}
{
  "type": "order.paid",
  "data": {
    "order": {
      "orderId": "ORD_EXAMPLE",
      "merchantReference": "PUR_456",
      "merchantMetadata": {
        "merchant_reference": "PUR_456",
        "user_id": "USR_123",
        "source": "web"
      },
      "status": "PAID"
    }
  }
}
```

The webhook signature covers the exact raw body, including `merchantMetadata`. Signature
verification proves that Anyway delivered the payload; it does not make the original
buyer-visible query values suitable for authorization.

Never authorize access solely from `merchantMetadata`, a buyer note, email, or URL parameter. Compare the order, amount, currency, product, and reference with your own server-side record.

## Subscription payload

Subscription events use `data.subscription`. Important fields include:

| Field                         | Meaning                                                    |
| ----------------------------- | ---------------------------------------------------------- |
| `subscriptionId`, `orgId`     | Anyway subscription and organization                       |
| `status`, `provider`          | Canonical lifecycle state and payment provider             |
| `providerSubscriptionId`      | Provider-side subscription correlation identifier          |
| `customerId`, `customerEmail` | Customer identity when available                           |
| `product`                     | Optional product `id` and `name`                           |
| `billingInterval`             | `DAY`, `MONTH`, or `YEAR`                                  |
| `amountCents`, `currency`     | Minor-unit amount and lowercase settlement currency        |
| `currentPeriodEnd`            | Current paid-through time when available                   |
| `cancelAtPeriodEnd`           | Whether final cancellation is scheduled for the period end |
| `canceledAt`, `endedAt`       | Cancellation and final-end timestamps when available       |
| `createdAt`, `updatedAt`      | RFC 3339 UTC timestamps                                    |

```json theme={null}
{
  "type": "subscription.updated",
  "data": {
    "subscription": {
      "subscriptionId": "SUB_EXAMPLE",
      "orgId": "ORG_EXAMPLE",
      "providerSubscriptionId": "sub_example",
      "status": "ACTIVE",
      "customerId": "CUS_EXAMPLE",
      "customerEmail": "buyer@example.com",
      "product": {
        "id": "PRD_EXAMPLE",
        "name": "Pro Plan"
      },
      "billingInterval": "MONTH",
      "amountCents": 9900,
      "currency": "usd",
      "currentPeriodEnd": "2026-09-10T00:00:00Z",
      "cancelAtPeriodEnd": true,
      "createdAt": "2026-08-10T00:00:00Z",
      "updatedAt": "2026-08-10T08:00:00Z"
    }
  }
}
```

`merchantReference` and `merchantMetadata` belong to order payloads. They are not fields
of `data.subscription`; use `subscriptionId` to query related orders when you need the
initial checkout context.

## Delivery behavior

* Return a `2xx` quickly after durable enqueueing.
* Each attempt has a 10-second timeout.
* Transport failures, `408`, `429`, and `5xx` responses are retried.
* `3xx` responses and permanent `4xx` responses other than `408` and `429` are not retried.
* Delivery allows up to 12 attempts with exponential backoff capped at one hour.
* `Retry-After` is honored for `429` and `503`, up to the one-hour cap.
* Delivery is at least once and events can arrive out of order.

Use `webhook-id` as the delivery idempotency key. Do not let a late pending event regress a
locally paid order or let an older subscription update overwrite newer lifecycle state.

<Warning>
  Do not log webhook signatures, API keys, full payment details, identity documents, payout-account information, or unredacted customer metadata.
</Warning>
