Guide to Speed Webhooks: Features and Best Practices
Speed is a payments platform for Bitcoin, the Lightning Network and USDT on Ethereum and Tron. Merchants use it to take payments through checkout sessions, checkout links, payment links and invoices, and to send money out through withdrawals and withdrawal links. On-chain and Lightning payments settle on their own timeline, so Speed's webhooks push each change to your systems: a payment is paid or confirmed, a checkout link is fully paid, a withdrawal fails.
What are Speed webhooks?
A Speed webhook endpoint has a URL, an API version, a list of enabled events, and a choice between your own account and your connected accounts. Speed sends each event as a JSON POST to every endpoint that subscribed to it.
Signatures use the Standard Webhooks layout, webhook-id, webhook-timestamp and webhook-signature, with one twist: Speed's secrets start with wsec_, not whsec_. Standard Webhooks libraries and Hookdeck only base64-decode after a whsec_ prefix, so the secret needs its prefix changed before they can use it.
Speed webhook features
| Feature | Details |
|---|---|
| Configuration | Developers > Webhooks, or POST /webhooks |
| Scope | Own account or connected accounts, per endpoint |
| API versions | 2022-04-15 (default) or 2022-10-15 |
| Signature | Standard Webhooks HMAC-SHA256 layout |
| Signature header | webhook-signature |
| Secret | Per endpoint, wsec_<base64> |
| Body | JSON event object |
| Event routing | Body field event_type |
| Retries | On any non-2xx. Schedule not published in the API reference |
| Event history | 30 days in the web app, plus the Events API |
| Source IPs | Not published |
The skill's reference applications route every event type below.
Events and payloads
Every delivery is an event object:
event_typenames the event asresource.event, and the top-levelobjectis alwaysevents.data.objectis the resource. Its shape depends on the event and on the endpoint'sapi_version;2022-10-15addspayment_methodsandpayment_method_optionsto payments.- On Connect endpoints, events carry
data.account_idand, where relevant, application fee fields. id(evt_…) stays the same on every retry.webhook-iddoesn't.
Speed documents 28 event types:
| Group | Events |
|---|---|
| Payments | payment.created, payment.paid, payment.confirmed, payment.expired, payment.cancelled |
| Checkout sessions | checkout_session.created, checkout_session.paid, checkout_, checkout_ |
| Checkout links | checkout_link.created, checkout_, checkout_link.paid |
| Payment links | payment_link.created, payment_link.deactivated, payment_ |
| Payment addresses | payment_ |
| Payrequests | payrequest.payment_paid |
| Invoices | invoice.paid |
| Withdrawals | withdraw.created, withdraw.paid, withdraw.failed |
| Withdrawal links | withdrawal_link.created, withdrawal_link.paid, withdrawal_ |
| Withdraw requests | withdraw_request.created, withdraw_, withdraw_request.paid |
| Connect | connect.completed |
Speed's docs name the payment link deactivation event payment_link.deactivated, but the example body uses payment_link.deactivate. Match both until you've seen a real one. The withdraw.* and connect.completed examples show only the bare resource, without the event envelope.
This is the checkout_link.created sample from the webhook samples library, with Speed's placeholder values:
{
"api_version": "2022-04-15",
"data": {
"object": {
"amount": null,
"created": null,
"currency": null,
"id": "clink_xxxxxxxxxxxxxx",
"livemode": null,
"customer_collections_status": {
"is_billing_address_enabled": true,
"is_email_enabled": true,
"is_phone_enabled": true,
"is_shipping_address_enabled": true
},
"metadata": {},
"modified": null,
"object": "checkout.link",
"statement_descriptor": null,
"status": "active",
"target_currency": null,
"url": null
}
},
"event_type": "checkout_link.created",
"id": "evt_xxxxxxxxxxxxxx",
"livemode": null,
"object": "events",
"request": {
"id": "xxxxxxxxxxxxxxxxxxxx"
}
}
Setting up Speed webhooks
- Log in to the Speed web app and go to Dashboard > Developers > Webhooks.
- Click Add endpoint and enter your HTTPS URL, for example
https://your-app.com/webhooks/speed. - Choose your own account or connected accounts, add a description and pick an API version.
- Click Next, select events such as
payment.paid,payment.confirmedandcheckout_session.paid, and click Add endpoint. - Reveal the endpoint's secret on its detail page and store it in
SPEED_WEBHOOK_SECRET.
You can do the same with the API:
{
"url": "https://your-app.com/webhooks/speed",
"description": "orders",
"api_version": "2022-10-15",
"enabled_events": ["payment.paid", "payment.confirmed"],
"connect": false
}
Set connect to true to receive connected accounts' events instead of your own. Other calls retrieve and list endpoints, deactivate one with POST /webhooks/{id}/disable and reactivate it with POST /webhooks/{id}/enable.
Platform endpoints receive connected accounts' events with data.account_id set. If two platform accounts are linked to the same connected account and both subscribe, both receive its events, and the connected account's own endpoint receives them without account_id.
To route Speed through Hookdeck, create a source with the Speed type and register the source URL as the endpoint. When you paste the secret, follow the source's configuration note:
Speed shows the secret with a
wsec_prefix. Replacewsec_withwhsec_when you paste it as the source's verification secret, or verification fails.
Then forward the source locally with the Hookdeck CLI:
npx hookdeck-cli listen 3000 speed --path /webhooks/speed
Securing Speed webhooks
Speed's verification page builds the signed payload as webhook-id + "." + webhook-timestamp + "." + requestBody, removes wsec_ from the secret, base64-decodes the rest, and computes a base64 HMAC-SHA256. The function below accepts a secret with either prefix and checks each v1, entry in webhook-signature:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
secret = secret.replace(/^wsec_/, 'whsec_');
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
If you use a Standard Webhooks library instead, swap wsec_ for whsec_ first, because the library only strips whsec_.
Speed gives each retry a new webhook-id, timestamp and signature, so its own retries pass a five-minute window. Hookdeck's retries and replays keep the original webhook-timestamp, and Hookdeck applies no tolerance, so behind Hookdeck the function above leaves the age check out. For the same reason, verify when the request arrives rather than in a queue worker that might run minutes later.
The receiver keys each event on its body id, which survives retries:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// paymentLedger.record(eventId, event) stores the event once and resolves when durable.
function createSpeedReceiver(paymentLedger) {
const app = express();
app.post('/webhooks/speed', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.SPEED_WEBHOOK_SECRET; // wsec_ or whsec_ both work here
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
try {
await paymentLedger.record(event.id, event); // not webhook-id, which changes per retry
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
A Hookdeck Speed source returns 200 to Speed whatever the signature. Requests that fail are stored as failed verification and aren't forwarded.
Make Speed webhooks production-ready. Hookdeck Event Gateway verifies each Speed payment event, keeps endpoints from being deactivated during outages, and routes payments and withdrawals separately.
Speed webhook limitations and pain points
The wsec_ prefix
The Problem: Every delivery fails verification with a Standard Webhooks library or a Hookdeck source.
Why It Happens: Speed's secrets start with wsec_. Tools built for Standard Webhooks expect whsec_ and won't base64-decode anything else.
Workarounds:
- Replace
wsec_withwhsec_before passing the secret to a library, or use a manual verifier that accepts both.
How Hookdeck Can Help: Once the secret is pasted with whsec_, Hookdeck verifies every Speed delivery, and your services don't need to know about the prefix.
webhook-id changes on every retry
The Problem: Deduplicating on webhook-id lets retried payments through twice.
Why It Happens: Speed re-signs each retry with a new webhook-id.
Workarounds:
- Deduplicate on the body's
id(evt_…), which stays the same.
How Hookdeck Can Help: Hookdeck's deduplication can key on the body id, so a retried payment event reaches you once.
Retry schedule isn't published
The Problem: You can't tell how long Speed keeps retrying after an outage.
Why It Happens: Speed's API reference says non-2xx replies are retried but gives no schedule, count or timeout.
Workarounds:
- Reply
2xxas soon as the event is stored. - After an outage, list events with the Events API and process any you missed.
How Hookdeck Can Help: Hookdeck accepts each event immediately and retries your service on a schedule you set.
Deactivated endpoints lose events
The Problem: Payments made while an endpoint was deactivated never arrive.
Why It Happens: Speed emails you when an endpoint has failed for several days in a row and then deactivates it. It doesn't retry events generated while the endpoint is deactivated.
Workarounds:
- Act on the warning email, reactivate the endpoint, and backfill from the Events API, which the web app keeps for 30 days.
How Hookdeck Can Help: Hookdeck answers Speed even while your service is down, so the endpoint stays active and nothing is skipped.
Best practices
Fulfil on the right payment event
payment.paid and payment.confirmed are separate events. Decide which one your business treats as final before you ship goods, especially for on-chain payments.
Pin the API version
Each endpoint has its own api_version, and payload shapes follow it. Pick one deliberately and upgrade on purpose.
Watch withdrawals that fail
withdraw.failed mostly happens on Lightning sends, and Speed credits the deducted amount back. Update the user's balance and notify them.
Make processing idempotent
Use the event id as your key, as our guide to webhook idempotency describes.
Conclusion
Speed's webhooks cover crypto payments, payouts and Connect accounts with versioned payloads. The detail that catches people out is the wsec_ prefix, which has to become whsec_ for Standard Webhooks tools.
Hookdeck Event Gateway handles Speed's signature, keeps every payment event and retries your service on your terms. Start with a free Hookdeck account.