Verify webhooks
Table of contents
To prevent unauthorized requests, Replicate signs every webhook and its metadata with a unique key for each user or organization. You can use this signature to verify the webhook indeed comes from Replicate before you process it.
Why verify webhooks?
A webhook is an HTTP POST from an unknown source. Attackers can impersonate services by simply sending a fake webhook to an endpoint.
Another potential security hole is a replay attack, wherein an attacker intercepts a valid webhook payload (including the signature) and re-transmits it to your endpoint. This payload will pass signature validation, and will therefore be acted upon. To mitigate replay attacks, Replicate includes a timestamp indicating when the webhook attempt occurred.
Manually validating webhook data
Each webhook delivery includes three HTTP headers with additional information that you can use to verify the authenticity of the request:
webhook-id
: The unique message identifier for the webhook messages. This identifier is unique across all messages but will be the same when a webhook is being resent (e.g. retried).webhook-timestamp
: timestamp in seconds since epoch.webhook-signature
: the Base64 encoded list of signatures (space delimited).
Constructing the signed content
As a webhook receiver, you are responsible for constructing this signed content and performing the validation steps. To validate a webhook, the signed data must be constructed into a well-defined structure from the payload data (body), webhook-id
, and webhook-timestamp
headers.
The content to sign is composed by concatenating the id
, timestamp
, and data
, separated by the full-stop character (.
).
In code it will look something like:
const signedContent = `${webhook_id}.${webhook_timestamp}.${body}`
In the example above, body
is the raw body of the request. The signature is sensitive to any changes, so even a small change in the body will cause the signature to be completely different. This means that you should not change the body in any way before verifying.
Retrieving the webhook signing key
Replicate provides an API endpoint you can use to retrieve the signing key. The signing key is unique to your user or organization. The endpoint will return only the signing key associated with the API token and its corresponding user or organization.
For optimal performance of the webhook receiver, it is advised to locally cache the signing key. By doing so, you eliminate the need for the receiver to make a request to the Replicate API for validation every time a webhook is received.
GET https://api.replicate.com/v1/webhooks/default/secret
Example cURL request:
curl -s -X GET -H "Authorization: Bearer <paste-your-token-here>" \
https://api.replicate.com/v1/webhooks/default/secret
The response will be a JSON object with a single key
field:
{
"key": "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD"
}
Determining the expected signature
Replicate uses an HMAC with SHA-256 to sign its webhooks.
To calculate the expected signature, you should HMAC the signed_content
from above using the base64 portion of the signing secret (this is the part after the whsec_
prefix) as the key. For example, given a secret whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD
, you will want to use C2FVsBQIhrscChlQIMV+b5sSYspob7oD
.
Here’s an example of how you can calculate the signature:
const crypto = require('crypto');
const signedContent = `${webhook_id}.${webhook_timestamp}.${body}`
const secret = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD";
// Base64 decode the secret
const secretBytes = new Buffer(secret.split('_')[1], "base64");
const signature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
console.log(signature);
This generated signature should match one of the ones sent in the webhook-signature
header.
The webhook-signature
header is composed of a list of space-delimited signatures and their corresponding version identifiers. The signature list is most commonly of length one, though there could be any number of signatures. For example:
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= v2,MzJsNDk4MzI0K2VvdSMjMTEjQEBAQDEyMzMzMzEyMwo=
Make sure to remove the version prefix and delimiter (e.g. v1,
) before verifying the signature.
Please note that to compute the signatures it’s recommended to use a constant-time string comparison method in order to prevent timing attacks.
An example of how to do the signature verification:
const expectedSignatures = webhookSignatures.split(' ').map(sig => sig.split(',')[1]);
const isValid = expectedSignatures.some(expectedSignature => expectedSignature === computedSignature);
console.log(isValid);
Verify timestamp
As mentioned above, Replicate also sends the timestamp of the attempt in the webhook-timestamp
header. You should compare the timestamp against your system timestamp and make sure it’s within your tolerance in order to prevent replay attacks.
// Example of timestamp validation (5 minute tolerance)
const MAX_DIFF_IN_SECONDS = 5 * 60; // 5 minutes
const timestamp = parseInt(webhook_timestamp);
const now = Math.floor(Date.now() / 1000);
const diff = Math.abs(now - timestamp);
if (diff > MAX_DIFF_IN_SECONDS) {
console.error(`Webhook timestamp is too old: ${diff} seconds`);
return false;
}
Complete verification example
const express = require('express');
const crypto = require('crypto');
const app = express();
// Your webhook secret from Replicate
const WEBHOOK_SECRET = "whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD";
// Maximum age of webhook to accept (5 minutes)
const MAX_DIFF_IN_SECONDS = 5 * 60;
app.use(express.raw({ type: '*/*' }));
app.post('/replicate-webhook', (req, res) => {
try {
// Get webhook headers
const webhookId = req.headers['webhook-id'];
const webhookTimestamp = req.headers['webhook-timestamp'];
const webhookSignatures = req.headers['webhook-signature'];
// Validate required headers
if (!webhookId || !webhookTimestamp || !webhookSignatures) {
return res.status(400).json({ error: "Missing required headers" });
}
// Validate timestamp
const timestamp = parseInt(webhookTimestamp);
const now = Math.floor(Date.now() / 1000);
const diff = Math.abs(now - timestamp);
if (diff > MAX_DIFF_IN_SECONDS) {
return res.status(400).json({
error: `Webhook timestamp is too old: ${diff} seconds`
});
}
// Get raw request body as string
const body = req.body.toString();
// Construct the signed content
const signedContent = `${webhookId}.${webhookTimestamp}.${body}`;
// Get the secret key (remove 'whsec_' prefix)
const secretKey = WEBHOOK_SECRET.split('_')[1];
const secretBytes = Buffer.from(secretKey, 'base64');
// Calculate the HMAC signature
const computedSignature = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// Parse the webhook signatures
const expectedSignatures = webhookSignatures
.split(' ')
.map(sig => sig.split(',')[1]);
// Use constant-time comparison to prevent timing attacks
const isValid = expectedSignatures.some(expectedSig =>
crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(computedSignature)
)
);
if (!isValid) {
return res.status(403).json({ error: "Invalid webhook signature" });
}
// Parse and process the webhook
const prediction = JSON.parse(body);
console.log(`Processing webhook for prediction: ${prediction.id}`);
res.status(200).send();
} catch (error) {
console.error('Error processing webhook:', error);
res.status(400).json({ error: error.message });
}
});
app.listen(3000, () => {
console.log('Webhook server listening on port 3000');
});