# Guide to Speed Webhooks: Features and Best Practices

[Speed](https://www.tryspeed.com) 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](https://github.com/hookdeck/webhook-skills/tree/main/skills/speed-webhooks/examples) 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:

| Group | Events |
| --- | --- |
| Payments | `payment.created`, `payment.paid`, `payment.confirmed`, `payment.expired`, `payment.cancelled` |
| Checkout sessions | `checkout_session.created`, `checkout_session.paid`, `checkout_session.payment_paid`, `checkout_session.deactivated` |
| Checkout links | `checkout_link.created`, `checkout_link.deactivated`, `checkout_link.paid` |
| Payment links | `payment_link.created`, `payment_link.deactivated`, `payment_link.paymentreceived` |
| Payment addresses | `payment_address.paymentreceived` |
| Payrequests | `payrequest.payment_paid` |
| Invoices | `invoice.paid` |
| Withdrawals | `withdraw.created`, `withdraw.paid`, `withdraw.failed` |
| Withdrawal links | `withdrawal_link.created`, `withdrawal_link.paid`, `withdrawal_link.deactivated` |
| Withdraw requests | `withdraw_request.created`, `withdraw_request.deactivated`, `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](https://github.com/hookdeck/webhook-samples/blob/main/providers/speed/latest/checkout_link.created.json), with Speed's placeholder values:

```json
{
  "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](https://app.tryspeed.com/dashboard) 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:

```json
{
  "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](/docs/cli):

```bash
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`:

```javascript
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:

```javascript
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](/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](/docs/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](/docs/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](/webhooks/guides/implement-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](https://hookdeck.com) handles Speed's signature, keeps every payment event and retries your service on your terms. [Start with a free Hookdeck account](https://dashboard.hookdeck.com/signup).