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

# Webhooks

> Register Due webhook endpoints through the Portal Custodian API and verify deliveries with the endpoint Ed25519 key.

## Overview

Due delivers lifecycle events (KYC, transfers, virtual accounts) to a URL you register.
Webhooks are the recommended way to track status changes rather than checking state yourself.

Unlike some integrations, Due webhook endpoints are managed through the Portal Custodian API,
not a provider dashboard and not the Client API. You register a URL, subscribe to events, and
verify each delivery with the endpoint's Ed25519 public key.

<Note>
  These calls use a Custodian API key (`Authorization: Bearer [custodian-api-key]`), not a
  client session token. Endpoints are environment-scoped: register them separately for each
  environment (for example Development and Production). The shorthand paths below are relative
  to `/api/v3/custodians/me/integrations/due`.
</Note>

## 1. List available events

`GET /webhooks/events` returns the event types you can subscribe to.

```bash theme={null}
curl --request GET \
  --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks/events \
  --header 'Authorization: Bearer [custodian-api-key]'
```

## 2. Create a webhook endpoint

`POST /webhooks` registers your URL. Pass the events you want in `events`, or omit it
to receive all of them.

```bash theme={null}
curl --request POST \
  --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks \
  --header 'Authorization: Bearer [custodian-api-key]' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: [unique-key]' \
  --data '{
    "url": "https://example.com/webhooks/due",
    "description": "Production events",
    "events": ["transfer.status_changed", "virtual_account.updated", "bp.kyc.status_changed"]
  }'
```

Example response:

```json theme={null}
{
  "data": {
    "id": "whe_123",
    "url": "https://example.com/webhooks/due",
    "subscribedEvents": ["transfer.status_changed", "virtual_account.updated", "bp.kyc.status_changed"],
    "enabled": true,
    "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n"
  }
}
```

<Warning>
  Store the `publicKey`. It is the Ed25519 key you use to verify every delivery from this
  endpoint, and it is returned only in the endpoint's create and list responses.
</Warning>

## 3. Events to subscribe to

| Event                                     | Fires when                                                                                                                                        |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `transfer.created`                        | A transfer is created.                                                                                                                            |
| `transfer.status_changed`                 | A transfer moves through `awaiting_funds`, `funds_received`, `approved`, `payment_submitted`, `payment_processed` (and failure or refund states). |
| `virtual_account.created`                 | A virtual account is created.                                                                                                                     |
| `virtual_account.updated`                 | A virtual account changes state, including when it activates.                                                                                     |
| `bp.kyc.status_changed`                   | The customer's KYC status changes.                                                                                                                |
| `transfers.kyc.submission.status_changed` | A KYC submission changes state (provider-specific `sumsub` and `bridge` variants also exist).                                                     |
| `bp.tos_accepted`                         | The customer accepts a terms of service document.                                                                                                 |

`transfer.status_changed` tracks the same statuses shown in the [payins](/integrations/On-Off-Ramp/due-payins)
and [payouts](/integrations/On-Off-Ramp/due-payouts) guides, and `virtual_account.updated`
tells you when a [virtual account](/integrations/On-Off-Ramp/due-virtual-accounts) becomes
active.

## 4. Receive and verify deliveries

Due sends a `POST` to your URL for each event, with the signature hex-encoded in the
`X-Webhook-Signature` header. Verify it against the raw request body using the endpoint's
Ed25519 `publicKey` before trusting the payload.

```javascript theme={null}
import crypto from 'node:crypto'

function verifyDueWebhook(rawBody, signatureHex, publicKeyPem) {
  return crypto.verify(
    null,
    Buffer.from(rawBody),
    publicKeyPem,
    Buffer.from(signatureHex, 'hex'),
  )
}

// Express example. Capture the raw body (e.g. express.raw()) so the bytes match what Due signed.
app.post('/webhooks/due', express.raw({ type: '*/*' }), (req, res) => {
  const signature = req.header('X-Webhook-Signature')
  if (!signature || !verifyDueWebhook(req.body, signature, DUE_ENDPOINT_PUBLIC_KEY)) {
    return res.sendStatus(401)
  }

  res.sendStatus(200)
  const event = JSON.parse(req.body.toString('utf8'))
  processAsync(event)
})
```

<Warning>
  Verify the signature over the raw, unparsed body. Reframing the JSON changes the bytes and
  breaks verification. Reject any delivery that fails verification.
</Warning>

<Tip>
  Acknowledge deliveries quickly with a `2XX`, then process events asynchronously so a slow
  handler does not cause Due to retry.
</Tip>

## 5. Manage endpoints

* `GET /webhooks` lists your registered endpoints.
* `POST /webhooks/{webhookId}` updates one; send any of `url`, `description`, `events`,
  or `enabled` (set `enabled: false` to pause deliveries without deleting).
* `DELETE /webhooks/{webhookId}` removes one.

```bash theme={null}
curl --request POST \
  --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks/whe_123 \
  --header 'Authorization: Bearer [custodian-api-key]' \
  --header 'Content-Type: application/json' \
  --data '{ "enabled": false }'
```

## 6. Inspect and retry deliveries

`GET /webhooks/{webhookId}/events` returns the delivery history for an endpoint,
including `attempts`, `responseStatusCode`, and `lastError`. Retry a specific event with
`POST /webhooks/{webhookId}/events/{eventId}/retry`.

```bash theme={null}
curl --request GET \
  --url https://api.portalhq.io/api/v3/custodians/me/integrations/due/webhooks/whe_123/events \
  --header 'Authorization: Bearer [custodian-api-key]'
```

The response is paginated: when more events exist, `data.next` holds the cursor for the next
page. Portal forwards your query parameters to Due, so pass that value back as Due's pagination
cursor on the following request to page through the history.

<Note>
  In the event history, `eventData` is the event payload as a byte array. Decode it to JSON
  before reading, for example `JSON.parse(Buffer.from(eventData).toString('utf8'))`.
</Note>

## Next steps

* [KYC onboarding](/integrations/On-Off-Ramp/due-kyc)
* [Payins](/integrations/On-Off-Ramp/due-payins)
* [Payouts](/integrations/On-Off-Ramp/due-payouts)
* [Virtual accounts](/integrations/On-Off-Ramp/due-virtual-accounts)
