coal
coal

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

text
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:

bash
1# .env.local
2COAL_WEBHOOK_SECRET=whsec_live_...

Verification Algorithm

  1. Read the raw request body as bytes — do this before any JSON parsing.
  2. Compute HMAC-SHA256(rawBody, webhookSecret).
  3. Hex-encode the digest.
  4. Strip the sha256= prefix from the header value.
  5. Compare using a timing-safe equality function.
  6. 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

typescript
1import crypto from 'crypto';
2
3function verifyCoalSignature(
4 rawBody: string | Buffer,
5 signatureHeader: string,
6 secret: string
7): boolean {
8 const providedSig = signatureHeader.replace(/^sha256=/, '');
9
10 const expectedSig = crypto
11 .createHmac('sha256', secret)
12 .update(rawBody)
13 .digest('hex');
14
15 // Timing-safe comparison prevents timing attacks
16 try {
17 return crypto.timingSafeEqual(
18 Buffer.from(expectedSig, 'hex'),
19 Buffer.from(providedSig, 'hex')
20 );
21 } catch {
22 // Buffer lengths differ — signature is invalid
23 return false;
24 }
25}

Next.js App Router route handler

typescript
1// app/api/webhooks/coal/route.ts
2import { NextResponse } from 'next/server';
3import crypto from 'crypto';
4
5export 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') ?? '';
9
10 const secret = process.env.COAL_WEBHOOK_SECRET;
11 if (!secret) {
12 throw new Error('COAL_WEBHOOK_SECRET is not configured');
13 }
14
15 const isValid = verifyCoalSignature(rawBody, sigHeader, secret);
16
17 if (!isValid) {
18 console.error('[webhook] Signature mismatch — returning 400');
19 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
20 }
21
22 const event = JSON.parse(rawBody);
23 // ... handle event
24 return NextResponse.json({ received: true });
25}
26
27function 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

python
1import hashlib
2import hmac
3import os
4
5def 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=")
8
9 expected_sig = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
10
11 # hmac.compare_digest is timing-safe
12 return hmac.compare_digest(expected_sig, provided_sig)
13
14
15# Flask example
16from flask import Flask, request, abort, jsonify
17
18app = Flask(__name__)
19
20@app.route("/webhooks/coal", methods=["POST"])
21def coal_webhook():
22 raw_body = request.get_data() # raw bytes, before any parsing
23 sig_header = request.headers.get("X-Coal-Signature", "")
24
25 if not verify_coal_signature(raw_body, sig_header):
26 abort(400, description="Invalid signature")
27
28 event = request.get_json()
29 print(f"Received event: {event['event']}")
30 return jsonify({"received": True})

Go

go
1package main
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "io"
8 "net/http"
9 "os"
10 "strings"
11)
12
13func verifyCoalSignature(rawBody []byte, signatureHeader, secret string) bool {
14 provided := strings.TrimPrefix(signatureHeader, "sha256=")
15
16 mac := hmac.New(sha256.New, []byte(secret))
17 mac.Write(rawBody)
18 expected := hex.EncodeToString(mac.Sum(nil))
19
20 // hmac.Equal is timing-safe
21 expectedBytes, _ := hex.DecodeString(expected)
22 providedBytes, err := hex.DecodeString(provided)
23 if err != nil {
24 return false
25 }
26 return hmac.Equal(expectedBytes, providedBytes)
27}
28
29func coalWebhookHandler(w http.ResponseWriter, r *http.Request) {
30 // Read raw body BEFORE any JSON decoding
31 rawBody, err := io.ReadAll(r.Body)
32 if err != nil {
33 http.Error(w, "cannot read body", http.StatusBadRequest)
34 return
35 }
36
37 sigHeader := r.Header.Get("X-Coal-Signature")
38 secret := os.Getenv("COAL_WEBHOOK_SECRET")
39
40 if !verifyCoalSignature(rawBody, sigHeader, secret) {
41 http.Error(w, "invalid signature", http.StatusBadRequest)
42 return
43 }
44
45 // Proceed with event handling ...
46 w.WriteHeader(http.StatusOK)
47 w.Write([]byte(`{"received":true}`))
48}

Common Mistakes

MistakeResultFix
Parsing JSON before computing HMACSignature always failsRead raw bytes first, parse after verification
Using == for string comparisonTiming attack vulnerabilityUse crypto.timingSafeEqual / hmac.compare_digest
Forgetting to strip sha256= prefixComparison always failsStrip prefix before comparison
Wrong secret (API key vs webhook secret)Signature always failsUse the Webhook Secret from Console → Settings
Verifying in development without real secretFalse negatives in testingSet COAL_WEBHOOK_SECRET in your .env.local

What to Do on Failure

If verification fails, your endpoint should:

  1. Return HTTP 400 (not 200 — do not silently acknowledge forged requests).
  2. Log the full incoming header and a truncated body for debugging.
  3. Trigger an alert if you see repeated failures from unexpected IPs.
typescript
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}