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.
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):
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-eventheader.
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.
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.
Dedupe across the 72-hour retry window
Dedupe on a payment/event id so retries don't double-process.
Make Token.io webhooks production-ready
Hookdeck verifies the Ed25519 signature, deduplicates, and durably queues every payment event
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 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 for free and handle Token.io webhooks reliably in minutes.