Gareth Wilson Gareth Wilson

Guide to Token.io Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPUT /webhook/config ({ config: { type: [...], url } }); one config per member/account
Signature headertoken-signature (base64url Ed25519 signature)
Event headertoken-event (the event type)
Signature schemeEd25519 over the exact raw POST body
Verification keyMember's Ed25519 public key (Dashboard > Settings > Member Information, base64url)
AcknowledgementReturn 200
RetriesExponential backoff (~10, 30, 70, 150 min) up to 72h (~10 attempts)
SDKtoken-io (npm) is a broad API client, not a webhook verifier

Common events

The event type arrives in the token-event header:

EventFires when
PAYMENT_STATUS_CHANGEDA payment's status changes
TRANSFER_STATUS_CHANGEDA transfer's status changes
REFUND_STATUS_CHANGEDA refund's status changes
VRP_STATUS_CHANGEDA variable recurring payment status changes
VIRTUAL_ACCOUNT_CREDIT_RECEIVEDA 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-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.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.