# Webhooks

Webhooks tell your server when something changes, so you do not have to poll: deposits, operations, and shipments.

## Add an endpoint

In the console, open **Webhooks** and add an endpoint URL. Choose **All events**, or pick specific event types. Copy the signing secret: it is shown only once.

You can also manage endpoints with the API:

```bash
curl -X POST "$PACKFLIP_BASE_URL/api/v2/webhooks" \
  -H "Authorization: Bearer $PACKFLIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/packflip", "subscribedEvents": ["*"] }'
```

The response includes `signingSecret` once. Endpoint URLs must be public HTTPS URLs on port 443. Sandbox and production endpoints are configured separately.

| Action | API |
| --- | --- |
| List endpoints | `GET /api/v2/webhooks` |
| Change URL or events | `PATCH /api/v2/webhooks/{id}` with `url` or `subscribedEvents` |
| Disable or enable | `PATCH` with `active`, or `DELETE` to disable |
| Rotate the secret | `PATCH` with `"rotateSigningSecret": true`; the response has the new secret |
| Delivery history | `GET /api/v2/webhooks/{id}/deliveries` |
| Resend an event | `POST /api/v2/webhooks/{id}/replay` with `{ "eventId": "evt_…" }` |

## Events

| Type | When |
| --- | --- |
| `balance.topup.detected` | A USDC deposit reached your address and is being verified. |
| `balance.topup.confirmed` | A deposit was verified and credited. |
| `balance.topup.failed` | A detected deposit failed verification. |
| `operation.completed` | An order or mint finished. |
| `operation.awaiting_chain` | Mint or on-chain buyback authorizations are ready to submit. |
| `operation.onchain_failed` | A reported mint transaction failed or did not match. |
| `operation.onchain_cancelled` | An expired mint authorization was cancelled. |
| `operation.cancelled` | An on-chain operation ended with nothing confirmed. |
| `buyback.completed` | A buyback finished and its credit was posted. |
| `refund.completed` | Sealed cards were refunded. |
| `redemption.completed` | Cards were redeemed for shipping. |
| `shipment.updated` | Packflip changed a shipment's status or tracking. |

Subscribing to `*` also delivers event types added in the future. Ignore types you do not handle.

## Payload

Every delivery is a `POST` with a JSON body:

```json
{
  "id": "evt_…",
  "type": "balance.topup.confirmed",
  "apiVersion": "2026-09-12",
  "environment": "production",
  "createdAt": "2026-09-17T08:00:00.000Z",
  "partnerId": "par_…",
  "data": {
    "fundingTransactionId": "ftx_…",
    "amountUsd": "500.000000",
    "chainId": 8453,
    "transactionHash": "0x…"
  }
}
```

`data` depends on the type and always carries the IDs you need to fetch the full object, such as `operationId` and `customerId`. Treat events as notifications: when in doubt, fetch the current state from the API.

### `shipment.updated`

Sent whenever Packflip changes a redemption's shipment: its status, carrier, tracking, or note. Re-recording the same values sends nothing.

```json
{
  "type": "shipment.updated",
  "data": {
    "operationId": "op_…",
    "customerId": "cus_…",
    "previousStatus": "created",
    "changedFields": ["status", "carrier", "trackingNumber", "trackingUrl"],
    "shipment": {
      "id": "shp_…",
      "status": "shipped",
      "carrier": "Yamato",
      "trackingNumber": "1234-5678-9012",
      "trackingUrl": "https://…",
      "statusReason": null,
      "updatedAt": "2026-09-18T02:00:00.000Z"
    }
  }
}
```

- `status` moves through `created`, `shipped`, `in_transit`, and `delivered`. `exception` means the shipment is on hold; `statusReason` explains why. For an address problem, correct it with `PUT /api/v2/operations/{operationId}/shipment/address` (see [Cards](/docs/cards)); the shipment returns to `created`.
- `changedFields` can include `shipmentInfo` when the address was corrected. The event never repeats the address; fetch the operation to read it.
- Use this event to notify your customer in your own product. Packflip emails them only when the redemption asked for `customer_email` (see [Cards](/docs/cards)).
- In the sandbox, move a shipment along yourself with `PUT /api/v2/operations/{operationId}/shipment` to test your handler.

## Verify the signature

Each delivery has three headers:

- `webhook-id`: the event ID
- `webhook-timestamp`: Unix time in seconds
- `webhook-signature`: `v1,` followed by the hex HMAC-SHA256 of `{webhook-timestamp}.{raw body}`, keyed with the endpoint's signing secret

Verify against the **raw** request body, before parsing JSON, and reject old timestamps to prevent replays.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPackflipWebhook(rawBody: string, headers: Headers, secret: string) {
  const timestamp = headers.get("webhook-timestamp") ?? "";
  const signature = headers.get("webhook-signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = `v1,${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Next.js route handler
export async function POST(request: Request) {
  const rawBody = await request.text();
  if (!verifyPackflipWebhook(rawBody, request.headers, process.env.PACKFLIP_WEBHOOK_SECRET!)) {
    return new Response("invalid signature", { status: 400 });
  }
  const event = JSON.parse(rawBody);
  // Deduplicate on event.id, then handle event.type.
  return new Response(null, { status: 204 });
}
```

## Delivery and retries

- Respond with any `2xx` status within 10 seconds. Do slow work after responding.
- Other responses, timeouts, and connection errors are retried with exponential backoff (2, 4, 8, … minutes, at most an hour apart), up to 8 attempts. After that the delivery is marked `failed`; replay it once your endpoint is fixed.
- Deliveries can arrive more than once and out of order. Deduplicate on `id` and do not assume ordering.
- Disabling an endpoint pauses its deliveries. Events are matched to endpoints when they happen, so changing subscriptions does not add deliveries for past events.

The console's **Webhooks** page shows recent deliveries, their status, and the last response code.
