# Guide to Token.io Webhooks: Features and Best Practices

Token.io webhooks notify your systems about open-banking payment activity: a payment or refund status changes, a VRP consent updates, a virtual account receives credit. If you're building A2A payments on Token.io, webhooks are how you react to bank payment events without polling.

This guide covers how Token.io webhooks work, the events you'll handle, how to verify the Ed25519 signature (asymmetric crypto, not HMAC), the retry behavior, and the best practices for production.

## What are Token.io webhooks?

Token.io webhooks are HTTP POSTs delivered to a URL you configure per member/account. Each is signed with an Ed25519 asymmetric signature (not HMAC, and not a JWS/JWT). The signature is in the `token-signature` header (base64url), and the event type is in a separate `token-event` header. You verify with your member's Ed25519 public key from the Token Dashboard, over the exact raw POST body.

## Token.io webhook features

| Feature | Details |
| --- | --- |
| Configuration | `PUT /webhook/config` (`{ config: { type: [...], url } }`); one config per member/account |
| Signature header | `token-signature` (base64url Ed25519 signature) |
| Event header | `token-event` (the event type) |
| Signature scheme | Ed25519 over the exact raw POST body |
| Verification key | Member's Ed25519 public key (Dashboard > Settings > Member Information, base64url) |
| Acknowledgement | Return 200 |
| Retries | Exponential backoff (~10, 30, 70, 150 min) up to 72h (~10 attempts) |
| SDK | `token-io` (npm) is a broad API client, not a webhook verifier |

## Common events

The event type arrives in the `token-event` header:

| Event | Fires when |
| --- | --- |
| `PAYMENT_STATUS_CHANGED` | A payment's status changes |
| `TRANSFER_STATUS_CHANGED` | A transfer's status changes |
| `REFUND_STATUS_CHANGED` | A refund's status changes |
| `VRP_STATUS_CHANGED` | A variable recurring payment status changes |
| `VIRTUAL_ACCOUNT_CREDIT_RECEIVED` | A virtual account receives credit |

Payment notifications carry a `payment` object with a `status` (`INITIATION_PROCESSING`, `INITIATION_COMPLETED`, `INITIATION_REJECTED`) plus the raw `bankPaymentStatus`. Branch on the `token-event` header and the payment status.

## Setting up Token.io webhooks

Subscribe with `PUT /webhook/config` passing `{ "config": { "type": [...], "url": "..." } }` (read via `GET`, remove via `DELETE`). There's one config per member/account. Copy your member's Ed25519 public key from the Dashboard under Settings > Member Information (base64url, no padding).

## Securing Token.io webhooks

The `token-signature` header is a base64url Ed25519 signature over the exact raw POST body. Verify it with your member's Ed25519 public key. Because the keys are raw Ed25519 (base64url), a library that takes raw keys is cleanest, `tweetnacl` in Node, `PyNaCl` in Python.

```javascript
const nacl = require("tweetnacl");

// Member public key from the Dashboard (base64url, 32 raw bytes)
const PUBLIC_KEY = Buffer.from(process.env.TOKEN_PUBLIC_KEY, "base64url");

function verify(rawBody, signatureB64url) {
  const message = Buffer.from(rawBody); // exact raw body bytes
  const signature = Buffer.from(signatureB64url || "", "base64url");
  if (signature.length !== 64) return false;
  return nacl.sign.detached.verify(message, signature, PUBLIC_KEY);
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers["token-signature"])) {
    return res.sendStatus(401);
  }

  const eventType = req.headers["token-event"];
  res.sendStatus(200); // acknowledge
  processQueue.add({ eventType, body: JSON.parse(req.body) }); // async
});

```

The same check in Python (using PyNaCl):

```python
import base64
import os
from nacl.signing import VerifyKey
from nacl.exceptions import BadSignatureError

PUBLIC_KEY = VerifyKey(base64.urlsafe_b64decode(os.environ["TOKEN_PUBLIC_KEY"] + "=="))

def verify(raw_body: bytes, signature_b64url: str) -> bool:
    try:
        signature = base64.urlsafe_b64decode(signature_b64url + "==")
        PUBLIC_KEY.verify(raw_body, signature)
        return True
    except (BadSignatureError, Exception):
        return False

```

## Token.io webhook limitations and pain points

### Ed25519, not HMAC

The Problem: Verification is asymmetric: an Ed25519 signature checked against your member's public key. There's no shared secret, so HMAC logic doesn't apply, and the signature is over the exact raw body.

Why It Happens: Token.io signs with Ed25519 rather than a shared-secret HMAC.

Workarounds:

* Verify the base64url Ed25519 signature with the member public key over the raw body, using a library that takes raw keys.

How Hookdeck Can Help: Hookdeck verifies the Ed25519 signature at the edge, so your application receives pre-verified events without embedding asymmetric crypto in your handler.

### The signed message is the exact raw body

The Problem: The signature covers the exact raw POST body. Any re-serialization (reordered keys, changed whitespace) breaks verification.

Why It Happens: Ed25519 signs the exact bytes.

Workarounds:

* Capture the raw body before parsing and verify against it.

How Hookdeck Can Help: Hookdeck verifies against the bytes as received.

### The event type is in a header

The Problem: The event type is in the `token-event` header, not (only) the body. Handlers that switch on a body field may miss it.

Why It Happens: Token.io carries the event type as a header.

Workarounds:

* Branch on the `token-event` header.

How Hookdeck Can Help: Hookdeck's filters can route on the `token-event` header directly.

### Retries and duplicates

The Problem: Failed deliveries retry with exponential backoff up to 72 hours, so the same event can arrive more than once.

Why It Happens: At-least-once delivery favors eventual delivery of payment events.

Workarounds:

* Dedupe on a payment/event identifier and make side effects idempotent.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge, so retries don't double-process. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify the Ed25519 signature over the raw body

Verify the base64url `token-signature` with your member's Ed25519 public key over the exact raw body, using a raw-key library (`tweetnacl` / `PyNaCl`).

### Branch on the `token-event` header

Route on the event type header and the payment status.

### Acknowledge, then process asynchronously

Return 200 and defer work to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Dedupe across the 72-hour retry window

Dedupe on a payment/event id so retries don't double-process.

## Conclusion

Token.io webhooks are signed with Ed25519, verified against your member's public key over the exact raw body, with the event type in the `token-event` header. Verify with a raw-key library (not HMAC), branch on the event header, acknowledge, and dedupe across the 72-hour retry window.

[Hookdeck](https://hookdeck.com) verifies the Ed25519 signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique payment events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Token.io webhooks reliably in minutes.