Webhooks
Receive a POST notification when a job completes instead of polling. Pass webhookUrl and webhookSecret when submitting a job and your endpoint will be called as soon as the job reaches a terminal state.
Configuring Webhooks
Include these fields as form fields alongside your file upload in POST /convert:
| Field | Required | Description |
|---|---|---|
webhookUrl | Yes | HTTPS endpoint that will receive webhook POST requests |
webhookSecret | Yes | Shared secret for signature verification (16–256 characters) |
webhookEvents | No | Comma-separated event filter: succeeded, failed, or both (default: succeeded,failed) |
Example
curl -sS -X POST "https://api.tools.fast/convert" \
-H "X-Fast-Api-Key: $API_KEY" \
-F "file=@photo.heic" \
-F "targetFormat=jpg" \
-F "webhookUrl=https://example.com/webhooks/fast" \
-F "webhookSecret=whsec_your_secret_here" \
-F "webhookEvents=succeeded,failed"
URL Requirements
- Must use HTTPS (HTTP is rejected)
- Must use a hostname (IP addresses are not allowed)
- Maximum 2,048 characters
- Cannot target
.local,.internal, or.localhostdomains
Payload Schema
Webhooks are delivered as POST requests with a JSON body:
{
"event": "job.succeeded",
"eventId": "019502a3-7c1e-7f8a-9b2d-4e6f8a1b3c5d",
"jobId": "abc123",
"jobType": "convert",
"site": "convert.fast",
"status": "Succeeded",
"deliveryAttempt": 1,
"output": {
"fileName": "document.pdf",
"contentType": "application/pdf",
"fileCount": 1,
"downloadUrl": "https://s1.convert.fast/api/convert/job/abc123/download?token=...",
"expiresAt": "2026-02-27T10:00:00Z"
},
"metrics": {
"inputBytes": 204800,
"outputBytes": 51200,
"creditCost": 1,
"processingTimeMs": 1500
},
"completedAt": "2026-02-26T10:00:00Z"
}
Field Reference
| Field | Type | Present | Description |
|---|---|---|---|
event | string | Always | job.succeeded or job.failed |
eventId | string | Always | Unique identifier for this webhook event (idempotency key) |
jobId | string | Always | The job ID |
jobType | string | Always | convert |
site | string | Always | convert.fast |
status | string | Always | Succeeded or Failed |
deliveryAttempt | int | Always | Which delivery attempt (1–5) |
output | object | On success | Present only when event is job.succeeded |
output.fileName | string | On success | Primary output filename |
output.contentType | string | On success | MIME type of the output |
output.fileCount | int | On success | Number of output files |
output.downloadUrl | string | On success | Presigned download URL (see below) |
output.expiresAt | string | On success | When the download URL expires (ISO 8601 UTC) |
error | object | On failure | Present only when event is job.failed |
error.code | string | On failure | Machine-readable error code |
error.detail | string | On failure | Human-readable error description (max 256 chars) |
metrics | object | Always | Processing metrics |
metrics.inputBytes | long | Always | Input file size in bytes |
metrics.outputBytes | long | On success | Output file size in bytes |
metrics.creditCost | int | Always | Credits consumed |
metrics.processingTimeMs | long | When available | Processing duration in milliseconds |
completedAt | string | Always | When the job completed (ISO 8601 UTC) |
Failed Job Payload
{
"event": "job.failed",
"eventId": "019502a3-8d2f-7a1b-9c3e-5f7a2b4d6e8f",
"jobId": "abc123",
"jobType": "convert",
"site": "convert.fast",
"status": "Failed",
"deliveryAttempt": 1,
"error": {
"code": "jobs.timeout",
"detail": "Processing exceeded timeout limit"
},
"metrics": {
"inputBytes": 204800,
"outputBytes": null,
"creditCost": 0,
"processingTimeMs": null
},
"completedAt": "2026-02-26T10:05:00Z"
}
Signature Verification
Every webhook includes an X-Fast-Signature header for verifying authenticity. The format follows Stripe's convention:
X-Fast-Signature: t=1708981200,v1=a1b2c3d4e5f6...
Verification Algorithm
- Extract the
t(timestamp) andv1(signature) values from the header - Construct the signed content:
{timestamp}.{raw_json_body} - Compute HMAC-SHA256 using your webhook secret as the key
- Compare the computed hex digest with
v1using constant-time comparison
Additional Headers
| Header | Description |
|---|---|
X-Fast-Signature | t={unix_timestamp},v1={hex_hmac_sha256} |
X-Fast-Event-Id | The eventId (same as in the JSON body) |
X-Fast-Delivery-Attempt | The attempt number (same as deliveryAttempt) |
Content-Type | application/json |
Code Examples
Node.js
import crypto from "crypto";
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=", 2))
);
const timestamp = parts.t;
const signature = parts.v1;
const signedContent = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedContent)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex")
);
}
// Express middleware example
app.post("/webhooks/fast", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-fast-signature"];
if (!verifyWebhookSignature(req.body.toString(), sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Process event using event.eventId for idempotency
console.log(`${event.event} for job ${event.jobId}`);
res.status(200).send("OK");
});
Python
import hashlib
import hmac
def verify_webhook_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
signed_content = f"{timestamp}.{raw_body.decode()}"
expected = hmac.new(
secret.encode(), signed_content.encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
@app.route("/webhooks/fast", methods=["POST"])
def handle_webhook():
sig = request.headers.get("X-Fast-Signature")
if not verify_webhook_signature(request.data, sig, WEBHOOK_SECRET):
return "Invalid signature", 401
event = request.json
# Process event using event["eventId"] for idempotency
print(f"{event['event']} for job {event['jobId']}")
return "OK", 200
C#
using System.Security.Cryptography;
using System.Text;
bool VerifyWebhookSignature(string rawBody, string signatureHeader, string secret)
{
var parts = signatureHeader.Split(',')
.Select(p => p.Split('=', 2))
.ToDictionary(p => p[0], p => p[1]);
var timestamp = parts["t"];
var signature = parts["v1"];
var signedContent = $"{timestamp}.{rawBody}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedContent));
var expected = Convert.ToHexStringLower(hash);
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expected));
}
Retry Policy
If your endpoint does not return a 2xx status code, the webhook is retried with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1 | Immediate (initial delivery) |
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 30 seconds |
| 5 | 120 seconds (2 minutes) |
Maximum attempts: 5 (total time window: ~2.5 minutes)
Retryable vs Terminal Responses
| Status Code | Behavior |
|---|---|
| 2xx | Success — delivery complete |
| 429 | Retryable — too many requests |
| 500+ | Retryable — server error |
| Connection failure | Retryable — timeout or DNS error |
| 4xx (except 429) | Terminal — no more retries |
Timeout: Your endpoint must respond within 10 seconds.
Presigned Download URLs
When a job succeeds, the webhook payload includes a output.downloadUrl field containing a presigned URL:
https://s1.convert.fast/api/convert/job/{jobId}/download?token={encrypted_token}
- Authentication: The
tokenquery parameter contains an encrypted, tamper-proof token. No additional authentication headers are needed. - Expiration: URLs expire at the time indicated by
output.expiresAt(1 hour after job completion). - Reusable: Each URL can be used for multiple downloads until it expires.
- Expired URLs: Return HTTP 401 (invalid/expired token) or HTTP 410 (artifacts deleted).
To download:
curl -o output.pdf "https://s1.convert.fast/api/convert/job/abc123/download?token=..."
Event Filtering
Use webhookEvents to receive only the events you care about:
webhookEvents=succeeded— only success notificationswebhookEvents=failed— only failure notificationswebhookEvents=succeeded,failed— both (default)
Events not matching your filter are silently skipped (no HTTP request is made).
Idempotency
Each webhook delivery includes an eventId that is stable across retry attempts. Use this to deduplicate:
- When you receive a webhook, check if you've already processed that
eventId - If yes, return
200 OKwithout reprocessing - If no, process the event and store the
eventId
The eventId is unique per job completion event. Retries of the same delivery carry the same eventId with an incremented deliveryAttempt.