Sign in to fill these examples with your own keys. Sign in Start free trial

Verify webhook requests

Check the HMAC-SHA256 signature on every webhook before you trust it.

Every webhook POST is signed with your endpoint's secret. Recompute the signature from the raw body and compare it before you parse the JSON.

How signing works

Header Value
X-Capsule-Timestamp Unix time, as a string, when the request was signed.
X-Capsule-Signature sha256= followed by the hex HMAC-SHA256 of {timestamp}.{body}, keyed with the secret.

{body} is the exact bytes of the request body. Delivery webhooks use the endpoint's Signing secret (secret in the API). Inbox webhooks use the same headers, keyed with the inbox's webhook_secret.

Verify a request

  1. Read the raw request body before any JSON parsing. Re-serialized JSON won't match.
  2. Read X-Capsule-Timestamp and X-Capsule-Signature.
  3. Compute the HMAC-SHA256 of {timestamp}.{body} with your secret, as lowercase hex.
  4. Strip sha256= from the header and compare the two values in constant time.
  5. Reject the request with 401 if they don't match.

Each attempt, including a retry, is signed with a fresh timestamp. To guard against replayed requests, you can also reject timestamps more than a few minutes old.

With an SDK

The Node.js and Ruby SDKs include a verify helper. Install them from SDKs.

verify.js
1import { PostShiba } from "@postshiba/node" 2 3const postshiba = new PostShiba(process.env.POSTSHIBA_API_KEY) 4 5const ok = postshiba.webhooks.verify( 6 rawBody, 7 request.headers["x-capsule-timestamp"], 8 request.headers["x-capsule-signature"], 9 process.env.POSTSHIBA_WEBHOOK_SECRET 10)
verify.rb
1require "postshiba" 2 3ok = PostShiba::Webhooks.verify( 4 payload: request.raw_post, 5 signature: request.headers["X-Capsule-Signature"], 6 timestamp: request.headers["X-Capsule-Timestamp"], 7 secret: ENV["POSTSHIBA_WEBHOOK_SECRET"] 8)

Without an SDK

Any HMAC library works. These use only the standard library.

verify-express.js
1import express from "express" 2import { createHmac, timingSafeEqual } from "node:crypto" 3 4const app = express() 5 6app.post("/webhooks/postshiba", express.raw({ type: "application/json" }), (req, res) => { 7 const timestamp = req.get("X-Capsule-Timestamp") || "" 8 const given = (req.get("X-Capsule-Signature") || "").replace(/^sha256=/, "") 9 const expected = createHmac("sha256", process.env.POSTSHIBA_WEBHOOK_SECRET) 10 .update(`${timestamp}.${req.body}`) 11 .digest("hex") 12 13 const a = Buffer.from(expected) 14 const b = Buffer.from(given) 15 if (a.length !== b.length || !timingSafeEqual(a, b)) { 16 return res.sendStatus(401) 17 } 18 19 for (const event of JSON.parse(req.body)) { 20 console.log(event.event, event.email, event.sg_event_id) 21 } 22 res.sendStatus(204) 23})
verify.py
1import hashlib 2import hmac 3import os 4 5 6def verify(raw_body: bytes, timestamp: str, signature: str) -> bool: 7 secret = os.environ["POSTSHIBA_WEBHOOK_SECRET"].encode() 8 signed = timestamp.encode() + b"." + raw_body 9 expected = hmac.new(secret, signed, hashlib.sha256).hexdigest() 10 given = signature.removeprefix("sha256=") 11 return hmac.compare_digest(expected, given)
verify-openssl.rb
1require "openssl" 2 3def verify(raw_body, timestamp, signature) 4 expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("POSTSHIBA_WEBHOOK_SECRET"), "#{timestamp}.#{raw_body}") 5 given = signature.to_s.delete_prefix("sha256=") 6 OpenSSL.secure_compare(expected, given) 7end