Gareth Wilson Gareth Wilson

Guide to Speed Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDevelopers > Webhooks, or POST /webhooks
ScopeOwn account or connected accounts, per endpoint
API versions2022-04-15 (default) or 2022-10-15
SignatureStandard Webhooks HMAC-SHA256 layout
Signature headerwebhook-signature
SecretPer endpoint, wsec_<base64>
BodyJSON event object
Event routingBody field event_type
RetriesOn any non-2xx. Schedule not published in the API reference
Event history30 days in the web app, plus the Events API
Source IPsNot published

The skill's reference applications route every event type below.

Events and payloads

Every delivery is an event object:

  • event_type names the event as resource.event, and the top-level object is always events.
  • data.object is the resource. Its shape depends on the event and on the endpoint's api_version; 2022-10-15 adds payment_methods and payment_method_options to payments.
  • On Connect endpoints, events carry data.account_id and, where relevant, application fee fields.
  • id (evt_…) stays the same on every retry. webhook-id doesn't.

Speed documents 28 event types:

GroupEvents
Paymentspayment.created, payment.paid, payment.confirmed, payment.expired, payment.cancelled
Checkout sessionscheckout_session.created, checkout_session.paid, checkout_session.payment_paid, checkout_session.deactivated
Checkout linkscheckout_link.created, checkout_link.deactivated, checkout_link.paid
Payment linkspayment_link.created, payment_link.deactivated, payment_link.paymentreceived
Payment addressespayment_address.paymentreceived
Payrequestspayrequest.payment_paid
Invoicesinvoice.paid
Withdrawalswithdraw.created, withdraw.paid, withdraw.failed
Withdrawal linkswithdrawal_link.created, withdrawal_link.paid, withdrawal_link.deactivated
Withdraw requestswithdraw_request.created, withdraw_request.deactivated, withdraw_request.paid
Connectconnect.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

  1. Log in to the Speed web app and go to Dashboard > Developers > Webhooks.
  2. Click Add endpoint and enter your HTTPS URL, for example https://your-app.com/webhooks/speed.
  3. Choose your own account or connected accounts, add a description and pick an API version.
  4. Click Next, select events such as payment.paid, payment.confirmed and checkout_session.paid, and click Add endpoint.
  5. 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. Replace wsec_ with whsec_ 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_ with whsec_ 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 2xx as 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.


Gareth Wilson

Gareth Wilson

Product Marketing

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