Skip to content

Webhook Security

Overview

Lipila signs outbound webhooks using HMAC-SHA256 so you can verify that requests are genuine and have not been tampered with. Each merchant has a unique signing secret.


The Signing Secret

Your webhook signing secret is a base64-encoded 32-byte random key. You can find it in your dashboard under Settings → Webhooks.

Keep your secret safe. Treat it like a password. Never log it, commit it to source control, or expose it in client-side code.


Webhook Headers

Every webhook request includes the following headers:

HeaderExampleDescription
webhook-id3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6cUnique identifier for this event. Stable across retries. Use as an idempotency key.
webhook-timestamp1674087231Unix timestamp (seconds since epoch) of when the attempt was made.
webhook-signaturev1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=Space-delimited list of signatures. Each signature is prefixed with a version identifier.

Signature Scheme

The signed content is constructed by concatenating the webhook ID, timestamp, and raw body with . delimiters:

{webhook-id}.{webhook-timestamp}.{raw_body}

Example:

3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c.1674087231.{"type":"transaction.completed","data":{...}}

This string is then signed using HMAC-SHA256 with your secret key. The output is base64 encoded and prefixed with v1,:

v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

Important: Always sign and verify against the raw request body bytes. Never parse the JSON and re-serialize it — even minor differences in whitespace or key ordering will produce a different signature.


Verifying Signatures

The StandardWebhooks library handles signature verification, timestamp validation, and header parsing automatically. Prefix your secret with whsec_ when passing it to the library. Signatures are prefixed with v1, per the Standard Webhooks specification.

python
from standardwebhooks import Webhook

wh = Webhook("whsec_" + secret)
wh.verify(payload, headers)  # raises WebhookVerificationException on failure
javascript
import { Webhook } from "standardwebhooks";

const wh = new Webhook("whsec_" + secret);
wh.verify(payload, headers);
go
import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"

wh, err := standardwebhooks.NewWebhook("whsec_" + secret)
if err != nil {
    // handle error
}
err = wh.Verify(body, headers)
php

$wh = new \StandardWebhooks\Webhook($base64Secret);
$wh->verify($payload, $headers);
csharp
using StandardWebhooks;

var wh = new StandardWebhook("whsec_" + secret);
wh.Verify(body, request.Headers);

Option 2 — Manual Implementation

If you are not using the StandardWebhooks library, follow these steps.

Step 1 — Extract headers

webhookId        = request.headers["webhook-id"]
webhookTimestamp = request.headers["webhook-timestamp"]
webhookSignature = request.headers["webhook-signature"]

Step 2 — Validate the timestamp

Reject the webhook if the timestamp is older than 5 minutes to prevent replay attacks:

age = now() - webhookTimestamp
if age > 300: reject

Step 3 — Construct the signed payload

signedPayload = webhookId + "." + webhookTimestamp + "." + rawBody

Step 4 — Compute the expected signature

Base64 decode your secret to obtain the raw 32 key bytes, then compute HMAC-SHA256:

python
import hmac, hashlib, base64

key = base64.b64decode("secret")
signed_payload = f"{webhook_id}.{webhook_timestamp}.{raw_body}"

expected = "v1," + base64.b64encode(
    hmac.new(key, signed_payload.encode(), hashlib.sha256).digest()
).decode()
javascript
import crypto from "crypto";

const key = Buffer.from("secret", "base64");
const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`;

const expected = "v1," + crypto
    .createHmac("sha256", key)
    .update(signedPayload)
    .digest("base64");
go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "fmt"
)

key, err := base64.StdEncoding.DecodeString("secret")
if err != nil {
    // handle error
}
signedPayload := fmt.Sprintf("%s.%s.%s", webhookId, webhookTimestamp, rawBody)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(signedPayload))
expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
php
$key = base64_decode("secret");
$signed_payload = $webhook_id . "." . $webhook_timestamp . "." . $raw_body;

$expected = "v1," . base64_encode(
    hash_hmac("sha256", $signed_payload, $key, true)
);
csharp
using System.Security.Cryptography;
using System.Text;

var key = Convert.FromBase64String("secret");
var signedPayload = $"{webhookId}.{webhookTimestamp}.{rawBody}";

using var hmac = new HMACSHA256(key);
var expected = "v1," + Convert.ToBase64String(
    hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload))
);

Step 5 — Compare signatures using constant-time comparison

The webhook-signature header may contain multiple space-delimited signatures during key rotation. Try each one until a match is found.

Never use == for signature comparison. Always use a constant-time comparison function to prevent timing attacks.

python
import hmac

received = webhook_signature.split(" ")
valid = any(
    hmac.compare_digest(expected, sig.strip())
    for sig in received
)
javascript
const received = webhookSignature.split(" ");
const valid = received.some(sig =>
    crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(sig.trim())
    )
);
go
import (
    "crypto/subtle"
    "strings"
)

valid := false
for _, sig := range strings.Split(webhookSignature, " ") {
    if subtle.ConstantTimeCompare([]byte(expected), []byte(strings.TrimSpace(sig))) == 1 {
        valid = true
        break
    }
}
php
$received = explode(" ", $webhook_signature);
$valid = false;
foreach ($received as $sig) {
    if (hash_equals($expected, trim($sig))) {
        $valid = true;
        break;
    }
}
csharp
var received = webhookSignature.Split(" ");
var valid = received.Any(sig =>
    CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(sig.Trim())
    )
);

Replay Attack Prevention

The webhook-timestamp header is included in the signed payload. You must reject webhooks where the timestamp is more than 5 minutes in the past. This prevents an attacker from capturing and replaying a valid webhook at a later time.


Idempotency

The webhook-id is stable across retry attempts for the same event. Store processed webhook IDs (e.g. in Redis with a 24-hour TTL) and skip processing if you have already seen the ID.


Key Rotation

During key rotation, Lipila will sign webhooks with both the old and new secret for a 24-hour overlap period. Both signatures are sent space-delimited in the webhook-signature header:

webhook-signature: v1,<new_signature> v1,<old_signature>

Your verification code should try each signature in the list until one matches. This allows you to rotate to the new secret without any downtime or missed webhooks.


Secret Format Reference

PropertyValue
AlgorithmHMAC-SHA256
Secret length32 bytes (256 bits)
Secret encodingbase64
Library prefixwhsec_ (prefix your secret when using the StandardWebhooks library)
Signature encodingbase64
Signature prefixv1, (per the Standard Webhooks spec)
Timestamp tolerance300 seconds (5 minutes)

Released under the MIT License.