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

# Webhooks

> Receive payment results on your server and verify they came from Paywire.

When an order reaches a result, Paywire sends a `POST` with a JSON body to the link's `callbackUrl`. Fulfil purchases from these webhooks: they arrive even if the payer closed the checkout.

## The request

```http theme={null}
POST /webhooks/paywire HTTP/1.1
Content-Type: application/json
Web3Getaway-Signature: t=1792833362,v1=5f0c2e9a4b...
Web3Getaway-Event-Id: 0193a0c9-1b2c-7d3e-8f4a-5b6c7d8e9f0a
Web3Getaway-Event-Type: order.settled
```

```json theme={null}
{
  "id": "0193a0c9-1b2c-7d3e-8f4a-5b6c7d8e9f0a",
  "type": "order.settled",
  "createdAt": "2026-10-24T09:16:02.511Z",
  "data": {
    "orderId": "0193a0c5-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
    "paymentLinkId": "0193a0c4-5f2e-7c1d-9b8a-3e4f5a6b7c8d",
    "status": "Settled",
    "previousStatus": "Confirming",
    "expectedAmount": { "network": "solana_devnet", "token": "native", "decimals": 9, "baseUnits": "301204819" },
    "confirmedAmount": { "network": "solana_devnet", "token": "native", "decimals": 9, "baseUnits": "301204819" },
    "fiat": { "amount": 49.90, "currency": "USD" }
  }
}
```

Event types: `order.settled`, `order.overpaid`, `order.expired`, `order.expired_underpaid`, `order.manual_review`, `order.refunded`. See [Payment statuses](https://docs.paywire.xyz/concepts/payment-statuses.md) for what each means.

## Respond quickly

Return any `2xx` within **10 seconds** to acknowledge. Do slow work (emails, fulfilment) after responding, or in a queue. Paywire doesn't follow redirects, so point `callbackUrl` at the final URL.

Anything else (a non-`2xx`, a timeout, a network error) is retried after 10 s, 30 s, 2 min, 10 min, 30 min, 1 h, 3 h and 6 h: 9 attempts over about 10 hours. After that the delivery is marked failed and a Paywire operator can resend it.

## Verify the signature

Anyone can post to your URL, so check every webhook. The `Web3Getaway-Signature` header holds a Unix timestamp `t` and `v1`, a hex HMAC-SHA256 of `"{t}.{raw body}"` keyed with your **webhook secret**:

1. Split the header on `,` and read `t` and `v1`.
2. Compute `HMAC-SHA256(secret, t + "." + rawBody)` as lowercase hex. Use the raw request bytes, before any JSON parsing.
3. Compare it with `v1` in constant time.
4. Reject a `t` more than 5 minutes from your clock, so an old webhook can't be replayed.

Get your secret (it starts with `whsec_`) with [`GET /notifications/merchants/me/webhook-secret`](https://docs.paywire.xyz/api-reference/webhooks/get-secret).

```javascript Node.js (Express) theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.PAYWIRE_WEBHOOK_SECRET; // whsec_...

app.post("/webhooks/paywire", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Web3Getaway-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${req.body}`).digest("hex");

  const valid =
    parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)) &&
    Math.abs(Date.now() / 1000 - t) <= 300;
  if (!valid) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  res.sendStatus(200); // acknowledge first, then work
  handle(event);
});
```

```python Python (Flask) theme={null}
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PAYWIRE_WEBHOOK_SECRET"].encode()  # whsec_...

@app.post("/webhooks/paywire")
def paywire_webhook():
    header = request.headers.get("Web3Getaway-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data()  # raw bytes
    expected = hmac.new(SECRET, f"{parts.get('t')}.".encode() + body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    if abs(time.time() - int(parts["t"])) > 300:
        abort(400)

    event = request.get_json()
    handle(event)
    return "", 200
```

```csharp C# (ASP.NET Core) theme={null}
using System.Security.Cryptography;
using System.Text;

app.MapPost("/webhooks/paywire", async (HttpRequest request, IConfiguration config) =>
{
    var secret = config["Paywire:WebhookSecret"]!; // whsec_...
    using var reader = new StreamReader(request.Body);
    var body = await reader.ReadToEndAsync();

    var parts = request.Headers["Web3Getaway-Signature"].ToString()
        .Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
    if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1) || !long.TryParse(t, out var seconds))
        return Results.BadRequest();

    var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{body}"));
    var expected = Convert.ToHexStringLower(mac);
    var fresh = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) <= 300;
    if (!fresh || !CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1)))
        return Results.BadRequest();

    // handle the event...
    return Results.Ok();
});
```

## Handle duplicates

The same event can arrive more than once: a retry after your server answered too slowly, or an operator's resend. Every copy has the same `Web3Getaway-Event-Id` (and body `id`). Record the ids you've processed and skip repeats, or make your handler idempotent, for example "mark purchase paid" rather than "add a payment".

Events about one order can also arrive out of order after retries. Use `status` as the truth, and if in doubt read the order with [`GET /payments/orders/{id}`](https://docs.paywire.xyz/api-reference/orders/get).

## Rotate the secret

```bash theme={null}
curl -X POST https://api.staging.paywire.xyz/notifications/merchants/me/webhook-secret/rotate \
  -H "Authorization: Bearer $PAYWIRE_API_KEY"
```

Deliveries from that moment are signed with the new secret, retries of earlier ones included. Deploy the new secret right after rotating, or accept both for a few minutes while you switch.


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