Agent skill

Speed Webhooks Skill

Receive and verify Speed (tryspeed.com) webhooks. Use when setting up a Speed webhook endpoint for Bitcoin Lightning payments, handling the wsec_ signing secret, debugging webhook-id / webhook-timestamp / webhook-signature (Standard Webhooks) verification, or handling events like payment.paid, payment.confirmed, checkout_session.paid and withdraw.failed.

Install this skill

npx skills add hookdeck/webhook-skills --skill speed-webhooks


Speed is a Bitcoin and Lightning payments platform (USDT on Ethereum and Tron too). Its webhooks report payment, checkout session and link, payment link, invoice, withdrawal and Connect events. Every delivery is signed with Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), using a secret that starts with wsec_.

When to Use This Skill

  • How do I receive Speed webhooks?
  • How do I verify a Speed webhook signature?
  • Why does Speed's wsec_ secret not work with a Standard Webhooks library?
  • How do I handle payment.paid, payment.confirmed or checkout_session.paid events?
  • Why do Hookdeck retries of a Speed webhook fail verification after a few minutes?

Verification (core)

HeaderValue
webhook-idMessage ID. A retry gets a new one
webhook-timestampUnix seconds of this attempt
webhook-signatureOne or more space-separated v1,<base64 HMAC-SHA256> entries

Signed content is {webhook-id}.{webhook-timestamp}.{raw body}. The key is the base64-decoded part of the secret after the prefix. Speed shows the secret as wsec_<base64>, but the standardwebhooks library (npm, PyPI) only strips whsec_, so swap the prefix first:

const { Webhook } = require('standardwebhooks');

// SPEED_WEBHOOK_SECRET is the wsec_ value exactly as Speed shows it
const secret = process.env.SPEED_WEBHOOK_SECRET.replace(/^wsec_/, 'whsec_');
// rawBody: exact request body string, NOT re-serialised JSON
const prefix = req.get('webhook-id') ? 'webhook' : 'svix'; // fall back to svix-* like Hookdeck
const event = new Webhook(secret).verify(rawBody, {
  'webhook-id': req.get(`${prefix}-id`),
  'webhook-timestamp': req.get(`${prefix}-timestamp`),
  'webhook-signature': req.get(`${prefix}-signature`),
}); // returns the parsed event; throws → respond 400

Python: from standardwebhooks.webhooks import Webhook, then Webhook(secret).verify(await request.body(), {...same three keys...}) with secret = "whsec_" + raw[len("wsec_"):]. Speed's own docs show only a Java sample with the same construction and call verification optional; keep it on anyway.

Hookdeck source secret. If you receive Speed webhooks through a Hookdeck Speed source, the configuration alert reads: "Speed shows the secret with a wsec_ prefix. Replace wsec_ with whsec_ when you paste it here, or verification fails."

Timestamp window. The library always enforces a fixed 5-minute window. Hookdeck retries and replays resend the original timestamp, so the library rejects late retries after 5 minutes. A manual verifier (HMAC-SHA256 over {id}.{timestamp}.{raw body}, every v1, entry compared in constant time after a length check, timestamp required to be a plain positive integer) where the window can be dropped or widened is in references/verification.md.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Event Envelope

{ "id": "evt_xxxxxxxxxx", "object": "events", "api_version": "2022-10-15",
  "event_type": "payment.paid",
  "data": { "object": { "id": "pi_xxxxxxxxxx", "object": "payment", "amount": 5000,
                        "currency": "SATS", "status": "paid" } },
  "livemode": false, "request": { "id": "xxxxxxxxxx" },
  "created": 1696847071760, "modified": 1696847071760 }

The event type is the top-level event_type; object is always events. data.object is the resource (payment, checkout session, ...). Events for connected accounts add data.account_id. Dedupe on the event id (evt_…): a retry keeps it but gets a new webhook-id.

Common Event Types

EventFires when
payment.created / payment.paid / payment.confirmed / payment.expired / payment.cancelledA payment is created, paid, confirmed, expires or is cancelled
checkout_session.created / .paid / .payment_paid / .deactivatedA checkout session changes
checkout_link.created / .paid / .deactivatedA checkout link changes
payment_link.created / .paymentreceived / .deactivatedA payment link changes
payment_address.paymentreceived / payrequest.payment_paid / invoice.paidA payment address, payrequest or invoice is paid
withdraw.created / withdraw.paid / withdraw.failedA withdrawal progresses
withdrawal_link.* / withdraw_request.*created, paid, deactivated on withdrawal links and requests
connect.completedA Connect account link completes

All 28 documented types (the list may not be exhaustive; Speed may add more): references/overview.md.

Delivery Semantics

  • Reply 2xx quickly. Any other status is retried: immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h (about 5 days of retries), then the endpoint is deactivated. Missing headers or a bad signature: return 400 (or 401).
  • Each retry is re-signed with a new webhook-id and timestamp. Speed does not store event data it sends: capture it.
  • No challenge or validation request is documented.

Environment Variables

SPEED_WEBHOOK_SECRET=wsec_xxxxx   # the value exactly as Speed shows it; handlers fail closed (500) without it

One secret per endpoint: reveal it under Developers > Webhooks in the Speed dashboard, or read secret from Get Webhook Endpoint By Id.

Local Development

# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 speed --path /webhooks/speed

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →