Signature Verification
Every webhook request that Coal sends includes an X-Coal-Signature header. Verifying this header guarantees that the request genuinely came from Coal and that the body has not been tampered with in transit.
Always verify signatures in production. An unverified webhook endpoint can be triggered by any party who knows your URL, enabling fraudulent fulfillment.
The Signature Header
1X-Coal-Signature: sha256=3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c
The value is always sha256= followed by a lowercase hexadecimal HMAC-SHA256 digest of the raw request body bytes.
Your Webhook Secret
Your webhook secret is separate from your API key. Find it in the Developer Console:
Console → Settings → Webhook Secret
Store it as an environment variable — never hardcode it:
1# .env.local2COAL_WEBHOOK_SECRET=whsec_live_...
Verification Algorithm
- Read the raw request body as bytes — do this before any JSON parsing.
- Compute
HMAC-SHA256(rawBody, webhookSecret). - Hex-encode the digest.
- Strip the
sha256=prefix from the header value. - Compare using a timing-safe equality function.
- If they match, the webhook is authentic.
The signature is computed over raw body bytes. Parsing the JSON first and re-serializing it will produce a different byte sequence (whitespace, key ordering) and cause verification to fail.
Code Examples
Node.js
1import crypto from 'crypto';23function verifyCoalSignature(4 rawBody: string | Buffer,5 signatureHeader: string,6 secret: string7): boolean {8 const providedSig = signatureHeader.replace(/^sha256=/, '');910 const expectedSig = crypto11 .createHmac('sha256', secret)12 .update(rawBody)13 .digest('hex');1415 // Timing-safe comparison prevents timing attacks16 try {17 return crypto.timingSafeEqual(18 Buffer.from(expectedSig, 'hex'),19 Buffer.from(providedSig, 'hex')20 );21 } catch {22 // Buffer lengths differ — signature is invalid23 return false;24 }25}
Next.js App Router route handler
1// app/api/webhooks/coal/route.ts2import { NextResponse } from 'next/server';3import crypto from 'crypto';45export async function POST(request: Request) {6 // Read raw bytes BEFORE any JSON.parse()7 const rawBody = await request.text();8 const sigHeader = request.headers.get('x-coal-signature') ?? '';910 const secret = process.env.COAL_WEBHOOK_SECRET;11 if (!secret) {12 throw new Error('COAL_WEBHOOK_SECRET is not configured');13 }1415 const isValid = verifyCoalSignature(rawBody, sigHeader, secret);1617 if (!isValid) {18 console.error('[webhook] Signature mismatch — returning 400');19 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });20 }2122 const event = JSON.parse(rawBody);23 // ... handle event24 return NextResponse.json({ received: true });25}2627function verifyCoalSignature(raw: string, header: string, secret: string): boolean {28 if (!header) return false;29 const provided = header.replace(/^sha256=/, '');30 const expected = crypto.createHmac('sha256', secret).update(raw).digest('hex');31 try {32 return crypto.timingSafeEqual(33 Buffer.from(expected, 'hex'),34 Buffer.from(provided, 'hex')35 );36 } catch {37 return false;38 }39}
Python
1import hashlib2import hmac3import os45def verify_coal_signature(raw_body: bytes, signature_header: str) -> bool:6 secret = os.environ["COAL_WEBHOOK_SECRET"].encode()7 provided_sig = signature_header.removeprefix("sha256=")89 expected_sig = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()1011 # hmac.compare_digest is timing-safe12 return hmac.compare_digest(expected_sig, provided_sig)131415# Flask example16from flask import Flask, request, abort, jsonify1718app = Flask(__name__)1920@app.route("/webhooks/coal", methods=["POST"])21def coal_webhook():22 raw_body = request.get_data() # raw bytes, before any parsing23 sig_header = request.headers.get("X-Coal-Signature", "")2425 if not verify_coal_signature(raw_body, sig_header):26 abort(400, description="Invalid signature")2728 event = request.get_json()29 print(f"Received event: {event['event']}")30 return jsonify({"received": True})
Go
1package main23import (4 "crypto/hmac"5 "crypto/sha256"6 "encoding/hex"7 "io"8 "net/http"9 "os"10 "strings"11)1213func verifyCoalSignature(rawBody []byte, signatureHeader, secret string) bool {14 provided := strings.TrimPrefix(signatureHeader, "sha256=")1516 mac := hmac.New(sha256.New, []byte(secret))17 mac.Write(rawBody)18 expected := hex.EncodeToString(mac.Sum(nil))1920 // hmac.Equal is timing-safe21 expectedBytes, _ := hex.DecodeString(expected)22 providedBytes, err := hex.DecodeString(provided)23 if err != nil {24 return false25 }26 return hmac.Equal(expectedBytes, providedBytes)27}2829func coalWebhookHandler(w http.ResponseWriter, r *http.Request) {30 // Read raw body BEFORE any JSON decoding31 rawBody, err := io.ReadAll(r.Body)32 if err != nil {33 http.Error(w, "cannot read body", http.StatusBadRequest)34 return35 }3637 sigHeader := r.Header.Get("X-Coal-Signature")38 secret := os.Getenv("COAL_WEBHOOK_SECRET")3940 if !verifyCoalSignature(rawBody, sigHeader, secret) {41 http.Error(w, "invalid signature", http.StatusBadRequest)42 return43 }4445 // Proceed with event handling ...46 w.WriteHeader(http.StatusOK)47 w.Write([]byte(`{"received":true}`))48}
Common Mistakes
| Mistake | Result | Fix |
|---|---|---|
| Parsing JSON before computing HMAC | Signature always fails | Read raw bytes first, parse after verification |
Using == for string comparison | Timing attack vulnerability | Use crypto.timingSafeEqual / hmac.compare_digest |
Forgetting to strip sha256= prefix | Comparison always fails | Strip prefix before comparison |
| Wrong secret (API key vs webhook secret) | Signature always fails | Use the Webhook Secret from Console → Settings |
| Verifying in development without real secret | False negatives in testing | Set COAL_WEBHOOK_SECRET in your .env.local |
What to Do on Failure
If verification fails, your endpoint should:
- Return HTTP 400 (not 200 — do not silently acknowledge forged requests).
- Log the full incoming header and a truncated body for debugging.
- Trigger an alert if you see repeated failures from unexpected IPs.
1if (!isValid) {2 console.error({3 msg: 'Coal webhook signature verification failed',4 sigHeader,5 ip: request.headers.get('x-forwarded-for'),6 bodyPreview: rawBody.slice(0, 120),7 });8 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });9}
