Agent skill
SparkPost Webhooks Skill
Receive and authenticate SparkPost event webhooks. Use when setting up SparkPost webhook handlers, debugging SparkPost webhook authentication (Basic Auth, OAuth 2.0 Bearer tokens, or the deprecated X-MessageSystems-Webhook-Token header), parsing the batched `msys` payload, or handling email events like delivery, bounce, click, open, spam_complaint, delay, and list_unsubscribe. SparkPost event webhooks are NOT signed — there is no HMAC and no signature header.
Install this skill
npx skills add hookdeck/webhook-skills --skill sparkpost-webhooks
When to Use This Skill
- How do I receive SparkPost webhooks?
- How do I authenticate SparkPost webhooks? (there is no signature to verify)
- Why is my SparkPost webhook returning 401 / why won't SparkPost create my webhook?
- How do I parse the SparkPost
msysbatch payload? - How do I handle
delivery,bounce,click,open,spam_complaintevents? - What is
X-MessageSystems-Batch-IDand how do I deduplicate batches? - How do I handle SparkPost relay webhooks (inbound email)?
Critical: SparkPost Event Webhooks Are Not Signed
There is no HMAC, no signature header, and no signing secret. Do not look for one — any code that computes createHmac / hmac.new for a SparkPost event webhook is wrong.
Authentication is credential-based and optional, set by the webhook's auth_type field, whose values are exactly none | basic | oauth2. From SparkPost's "Event Webhook Authentication and Security" doc: "The authentication method is set to 'None' by default when creating a new webhook."
| Mode | auth_type | What SparkPost sends |
|---|---|---|
| Basic Auth (recommended) | basic | Authorization: Basic base64(username:password) |
| OAuth 2.0 client credentials | oauth2 | Authorization: Bearer {token} |
| Legacy token (deprecated) | — (auth_token field) | X-MessageSystems-Webhook-Token: <token> |
| None (default) | none | nothing |
Your handler must fail closed: if no credentials are configured, reject. Never accept unauthenticated batches silently.
Not to be confused with Bird's new platform webhooks (bird.com). SparkPost is now owned by Bird, and the old
www.sparkpost.com/docs/tech-resources/webhook-authentication/URL 301-redirects to Bird's docs — but that is a different product with its own Standard Webhooks signing (webhook-id/webhook-timestamp/webhook-signature,whsec_secrets,email.delivered-style event names). None of that applies here.
Authentication (core)
Basic Auth is the primary path. Parse the header, split on the first colon only, and compare both halves in constant time:
const crypto = require('crypto');
// Fixed-length digest compare: safe even when the two strings differ in length.
const eq = (a, b) => crypto.timingSafeEqual(
crypto.createHash('sha256').update(a, 'utf8').digest(),
crypto.createHash('sha256').update(b, 'utf8').digest()
);
function verifyBasicAuth(authorizationHeader, username, password) {
if (!authorizationHeader || username === undefined) return false; // fail closed
const [scheme, encoded] = authorizationHeader.split(' ');
if (!encoded || scheme.toLowerCase() !== 'basic') return false;
const decoded = Buffer.from(encoded, 'base64').toString('utf8');
// Malformed base64 decodes to something without a colon — reject.
const colon = decoded.indexOf(':');
if (colon === -1) return false;
// Split on the FIRST colon only: passwords may contain colons.
const user = decoded.slice(0, colon);
const pass = decoded.slice(colon + 1);
// `password` is NOT required by SparkPost's API — an empty password is legitimate.
return eq(user, username) && eq(pass, password ?? '');
}
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The examples accept either a valid Basic header or Authorization: Bearer <token> (OAuth 2.0), plus the optional legacy X-MessageSystems-Webhook-Token. They also include a minimal demo POST /oauth/token endpoint. See references/verification.md.
There is no SDK verify helper — the sparkpost npm and sparkpost PyPI clients manage webhook configuration but have no receive/authenticate function. Use built-in crypto (crypto.timingSafeEqual, hmac.compare_digest) as above.
Payload: a JSON Array of msys-Wrapped Events
SparkPost POSTs a batch — a JSON array. Each element has a single msys key wrapping one event-class object, which carries the type field:
[
{ "msys": { "message_event": { "type": "delivery", "event_id": "92356927693813856", "message_id": "000443ee14578172be22", "timestamp": "1460989507" } } },
{ "msys": { "track_event": { "type": "click", "target_link_url": "http://example.com" } } }
]
Read the single key under msys — do not hardcode message_event — then switch on type.
| Wrapper key | Event types |
|---|---|
message_event | bounce, delivery, injection, spam_complaint, out_of_band, policy_rejection, delay, sms_status |
track_event | click, open, initial_open, amp_click, amp_open, amp_initial_open |
gen_event | generation_failure, generation_rejection |
unsubscribe_event | list_unsubscribe, link_unsubscribe |
relay_event | relay_injection, relay_rejection, relay_delivery, relay_tempfail, relay_permfail |
ab_test_event | ab_test_completed, ab_test_cancelled |
ingest_event | success, error |
Most scalar fields are strings even when numeric: "timestamp": "1460989507" (Unix seconds as a string), "num_retries": "2", "bounce_class": "1", "subaccount_id": "101". event_id format is not consistent across event types (a large integer for some, a UUID for others) — treat it as an opaque string.
The Test / Validation Batch
When a webhook is created (and when its target URL changes) SparkPost sends a test POST. "If this request does not receive an HTTP 200 response, your request to the Webhook API will fail with HTTP 400 and the webhook will not be created." POST /api/v1/webhooks/{id}/validate sends the documented sample batch:
[ { "msys": {} } ]
An array whose element has an empty msys object — no event class at all. Your handler must accept it and return 200 rather than throwing on the missing event key. There is no "ping" event type.
Environment Variables
# Basic auth (auth_type: "basic") — PRIMARY.
# These are credentials YOUR endpoint defines, NOT your SparkPost login.
SPARKPOST_WEBHOOK_USERNAME="basicauthuser"
SPARKPOST_WEBHOOK_PASSWORD="a-long-random-string" # may be empty: password is not required
# OAuth 2.0 (auth_type: "oauth2") — optional; used by the demo token endpoint.
SPARKPOST_OAUTH_CLIENT_ID="CLIENT123"
SPARKPOST_OAUTH_CLIENT_SECRET="9sdfj791d2bsbf"
# Legacy X-MessageSystems-Webhook-Token (deprecated; also used by relay webhooks) — optional.
SPARKPOST_WEBHOOK_TOKEN="existing-webhook-token"
Delivery, Retries, and Idempotency
- Respond 200. The create/validate test explicitly requires 200, and any non-2xx is retried.
- Timeout: 10 seconds per batch posting attempt.
- Retries increase logarithmically and stop after 8 hours — "12 total attempts will be made to POST the webhook batch (the initial attempt + 11 retries)".
- Batch size "may vary from 1 to 350 or more events", and a batch can mix event types.
- Webhooks only POST to ports 80 and 443. Events begin flowing ~1 minute after creation.
- Store the raw batch, respond 200, then process asynchronously: "if you do not return a 200 for the batch we will continue to resend even if you processed and stored part of the batch".
- Deduplicate on
X-MessageSystems-Batch-ID("useful for detecting and prevention of processing duplicate batches") and on each event's uniqueevent_id. Look the header up case-insensitively — SparkPost's support docs spell itX-Messagesystems-Batch-Id. A duplicate batch should still return 200.
Network Security
HTTPS is recommended. mTLS "is in route to deprecation on all regions at May 18th, 2026" — treat it as going away and don't build on it. For IP allowlisting SparkPost maintains the hostname wh.egress.sparkpost.com, "which lists the egress IPs under the host's A record"; non-Enterprise customers should allowlist that hostname rather than individual IPs. Never hardcode an IP list. custom_headers (e.g. {"x-api-key": "abcd"}) is sent on every batch POST and SparkPost suggests it as an additional measure — optional, not a substitute for auth.
Relay Webhooks (Inbound Email) Are a Separate API
/api/v1/relay-webhooks delivers inbound email content as msys.relay_message, and its auth_type enum is only none | oauth2 (no Basic Auth), plus auth_token → X-MessageSystems-Webhook-Token. Do not confuse relay_message (inbound email, relay webhooks) with relay_event (relay_injection / relay_delivery / … status events delivered via event webhooks). See references/overview.md.
Local Development
For local webhook testing, run the Hookdeck CLI via npx — no install required:
npx hookdeck-cli listen 3000 sparkpost --path /webhooks/sparkpost
No account required — the CLI creates a guest account on first run and provides a local tunnel plus a web UI for inspecting requests. Note that SparkPost only accepts targets on ports 80/443, so a tunnel (or Hookdeck) is required for local development.
Using Hookdeck in front of SparkPost: Hookdeck's
SPARKPOSTsource supports Basic Auth only (no OAuth 2.0 token-URL flow). Configure Basic Auth on the SparkPost webhook and the same credentials on the Hookdeck source.
Resources
- overview.md - Event classes, all event types, payload fields, relay webhooks
- setup.md - Create and configure the webhook (app + API), pick an auth mode
- verification.md - Basic Auth, OAuth 2.0, legacy token, gotchas, debugging
- examples/express/ - Express handler with tests
- examples/nextjs/ - Next.js App Router handler with tests
- examples/fastapi/ - FastAPI handler with tests