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

# Webhooks

> Register a customer webhook, verify the Pods signature, and handle ACTION_UPDATE deliveries

Pods can `POST` action updates to an HTTPS endpoint you control. Use this when you want push updates alongside `GET /actions/{id}` and the WebSocket at `wss://api.pods.finance/updates`.

The webhook body and the `GET /actions/{id}` body carry the same action object. The webhook adds a `messageType` envelope and three signature headers.

## Register the endpoint

The default URL is saved from the Pods admin panel with `PUT /customers/me/webhook`. Email [support@pods.finance](mailto:support@pods.finance) with:

* the HTTPS URL that should receive `POST` requests
* any extra headers Pods should send on every delivery, such as `Authorization: Bearer <token>`

Support stores that URL and those headers, then shares the signing secret with you over a secure channel. `GET /customers/me/webhook` and `PUT /customers/me/webhook` return only:

```json theme={null}
{
  "webhookURL": "https://example.com/webhooks/pods",
  "webhookHeaders": {
    "Authorization": "Bearer …"
  }
}
```

The response leaves out the signing secret. Ask support for the secret after the URL is saved. There is no API field for it.

The secret is one value per customer. It is `whsec_` plus 32 random bytes encoded as 64 hex characters. Pods creates it the first time the admin panel saves a `webhookURL`. If a delivery happens before that row exists, Pods creates the same style of secret at send time. A later save of the URL reuses the existing secret.

Headers you put in `webhookHeaders` are sent with every delivery. The names `X-Deframe-Webhook-Id`, `X-Deframe-Webhook-Timestamp`, and `X-Deframe-Webhook-Signature` are reserved. A customer header with one of those names is dropped, and the signature headers above are the ones that arrive.

A single quote or strategy bytecode request can pass `webhookURL` to deliver that operation to a different URL. That value overrides the customer default for that quote or action. The signing secret stays the customer secret.

| Call | Override |
| - | - |
| `GET /v2/swap/quote` | query `webhookURL` |
| `POST /v2/swap/bytecode` | body `webhookURL` |
| `GET /strategies/{strategyId}/bytecode` | query `webhookURL` |

## What arrives

| Field | Value |
| - | - |
| Method | `POST` |
| URL | The saved `webhookURL`, or the per-operation override |
| `Content-Type` | `application/json` |
| Body | `JSON.stringify({ messageType, data })` |

`ACTION_UPDATE` is the event to handle. `data` is the formatted action returned by `GET /actions/{id}`: same `id`, `type`, `status`, amounts, and `transactions`.

```json theme={null}
{
  "messageType": "ACTION_UPDATE",
  "data": {
    "id": "6a04f2f75bf7233c6220b3ca",
    "type": "CROSSCHAIN_INVESTMENT_DEPOSIT",
    "status": "SUCCESS"
  }
}
```

The snippet above is only the envelope. A live delivery includes the full action object.

Pods sends `ACTION_UPDATE` when an action that has a wallet address changes. You can receive several deliveries for one action (`INITIAL`, then `PENDING`, then `SUCCESS`, `FAILED`, `REFUND_REQUIRED`, or `REFUNDED`). `EXPIRED` stays on `GET /actions/{id}` and is not pushed. A provisional cross-chain transfer that the API later merges into the bridge action is also left off the webhook.

A successful strategy, transfer, or fiat-ramp action can be followed by a legacy `NEW_HISTORY` delivery. It uses the same signature and a different `data` object (`from`, `action`, `amount`, `fee`, `transactionHash`, `strategyId`, and related fields). Read status from `ACTION_UPDATE`. Acknowledge `NEW_HISTORY` with `2xx` if you do not use the legacy shape.

Other `messageType` values can arrive for specific routes. A TeleSwap bitcoin bridge can send `BTC_NATIVE_BRIDGE_TRANSACTION_STARTED`. Respond `2xx` for a type you do not handle, and keep the handler tolerant of new types.

## Signature

Every delivery is signed. Read the three headers, then verify them against the raw request body before `JSON.parse`.

| Header | Value |
| - | - |
| `X-Deframe-Webhook-Id` | `_id` of the `WebhookMessage` document, as a string |
| `X-Deframe-Webhook-Timestamp` | Unix time in seconds (`Math.floor(Date.now() / 1000)`) |
| `X-Deframe-Webhook-Signature` | `v1=` plus HMAC-SHA256 hex |

The signed text is this UTF-8 string:

```text theme={null}
${timestamp}.${webhookId}.${rawBody}
```

`rawBody` is the exact `POST` body: `JSON.stringify({ messageType, data })`. It is the bytes on the wire. A re-serialized object, a changed key order, or different whitespace produces a different signature. With Express, use `express.raw()` and verify `req.body` before you parse JSON.

Pods does not reject an old timestamp. A five-minute window and deduplication by `X-Deframe-Webhook-Id` are checks you can add in your handler. The [track and positions example](https://docs.pods.finance/examples/javascript/track-and-positions.md) shows both. They are not enforced by the sender.

### Worked example

This body is a signature fixture. A real `ACTION_UPDATE` uses an action `status` such as `SUCCESS`, not `confirmed`.

```text theme={null}
secret:    whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
id:        674a1b2c3d4e5f60718293a4
timestamp: 1783512000
body:      {"messageType":"ACTION_UPDATE","data":{"status":"confirmed"}}
```

Signed text:

```text theme={null}
1783512000.674a1b2c3d4e5f60718293a4.{"messageType":"ACTION_UPDATE","data":{"status":"confirmed"}}
```

Headers:

```text theme={null}
X-Deframe-Webhook-Id: 674a1b2c3d4e5f60718293a4
X-Deframe-Webhook-Timestamp: 1783512000
X-Deframe-Webhook-Signature: v1=b33e9026df818bb669129d01390c580f4097efcdaaca0a2c2bb03493e7c9c5f2
```

### Verify in Node

```javascript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const SECRET = process.env.WEBHOOK_SIGNING_SECRET // whsec_… from Pods support
const app = express()

app.post('/webhooks/pods', express.raw({ type: '*/*' }), (req, res) => {
  const webhookId = req.get('X-Deframe-Webhook-Id')
  const timestamp = req.get('X-Deframe-Webhook-Timestamp')
  const signatureHeader = req.get('X-Deframe-Webhook-Signature')
  if (!webhookId || !timestamp || !signatureHeader || !SECRET) {
    return res.sendStatus(401)
  }

  const rawBody = req.body.toString('utf8')
  const signed = `${timestamp}.${webhookId}.${rawBody}`
  const expected = createHmac('sha256', SECRET).update(signed, 'utf8').digest('hex')
  const provided = signatureHeader.replace(/^v1=/, '')
  const expectedBuffer = Buffer.from(expected, 'hex')
  const providedBuffer = Buffer.from(provided, 'hex')
  const valid = expectedBuffer.length === providedBuffer.length &&
    timingSafeEqual(expectedBuffer, providedBuffer)
  if (!valid) return res.sendStatus(401)

  const event = JSON.parse(rawBody)
  if (event.messageType === 'ACTION_UPDATE') {
    const action = event.data
    // persist action.id + action.status, then do the slow work after the response
  }

  res.sendStatus(200)
})
```

Compare the hex digests with a length check before `timingSafeEqual`. The function throws when the buffers differ in length.

## Delivery

Pods treats any HTTP `2xx` as accepted. A timeout, a network error, or any other status is stored on the delivery and is not retried. After you acknowledge, do the slow work on your side. If a delivery is missing, read `GET /actions/{id}` for the current action.

Keep the handler idempotent. The same action can move through more than one status, and each push has its own `X-Deframe-Webhook-Id`.


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