Appearance
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:
| Header | Example | Description |
|---|---|---|
webhook-id | 3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c | Unique identifier for this event. Stable across retries. Use as an idempotency key. |
webhook-timestamp | 1674087231 | Unix timestamp (seconds since epoch) of when the attempt was made. |
webhook-signature | v1,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
Option 1 — StandardWebhooks Library (recommended)
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 failurejavascript
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: rejectStep 3 — Construct the signed payload
signedPayload = webhookId + "." + webhookTimestamp + "." + rawBodyStep 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
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Secret length | 32 bytes (256 bits) |
| Secret encoding | base64 |
| Library prefix | whsec_ (prefix your secret when using the StandardWebhooks library) |
| Signature encoding | base64 |
| Signature prefix | v1, (per the Standard Webhooks spec) |
| Timestamp tolerance | 300 seconds (5 minutes) |