Webhooks¶
Receive real-time notifications when translation jobs complete, fail, or require review.
Configuration¶
Manage your webhook endpoint via the API. Each API key has its own webhook configuration.
GET /v1/webhooks/config¶
Get the current webhook configuration for the authenticated API key.
Authentication
Header: X-API-Key: your_api_key
Status: 200 OK
Response (200)¶
{
"webhook_url": "https://example.com/hooks/falara",
"webhook_secret_masked": "whsec_****abcd",
"active": true,
"updated_at": "2026-03-10T09:00:00+00:00"
}
Returns null fields and "active": false if no webhook is configured:
Examples¶
Errors¶
| Status | Description |
|---|---|
401 |
Invalid or missing API key. |
PUT /v1/webhooks/config¶
Create or update the webhook configuration for the authenticated API key.
Authentication
Header: X-API-Key: your_api_key
Content-Type: application/json
Status: 200 OK
Request Body¶
| Field | Type | Required | Description |
|---|---|---|---|
webhook_url |
string |
yes | HTTPS URL to receive events. |
webhook_secret |
string |
yes | Secret used for HMAC-SHA256 signing. |
Warning
The webhook_secret must be at least 16 characters. Use a cryptographically random string.
Response (200)¶
{
"webhook_url": "https://example.com/hooks/falara",
"webhook_secret_masked": "whsec_****here",
"active": true,
"updated_at": "2026-03-22T14:00:00+00:00"
}
Examples¶
const response = await fetch("https://app.falara.io/v1/webhooks/config", {
method: "PUT",
headers: {
"X-API-Key": FALARA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
webhook_url: "https://example.com/hooks/falara",
webhook_secret: "whsec_your_secret_here",
}),
});
console.log(await response.json());
Errors¶
| Status | Description |
|---|---|
400 |
URL not HTTPS, or secret too short. |
401 |
Invalid or missing API key. |
422 |
Invalid webhook URL (e.g. resolves to internal network). |
DELETE /v1/webhooks/config¶
Disable and remove the webhook configuration for the authenticated API key. Events will no longer be delivered.
Authentication
Header: X-API-Key: your_api_key
Status: 204 No Content
Examples¶
Errors¶
| Status | Description |
|---|---|
401 |
Invalid or missing API key. |
Signing & Verification¶
Every webhook delivery is signed with your webhook_secret using HMAC-SHA256. Two headers are included with each request:
| Header | Description |
|---|---|
X-Falara-Signature |
sha256={hex_digest} -- the HMAC-SHA256 signature. |
X-Falara-Timestamp |
Unix timestamp (seconds) when the payload was signed. |
The signed message is constructed as:
Verification Examples¶
import hashlib
import hmac
import time
def verify_webhook(body: bytes, signature_header: str, timestamp_header: str, secret: str) -> bool:
"""Verify the Falara webhook signature."""
# 1. Reject stale timestamps (>5 minutes old)
timestamp = int(timestamp_header)
if abs(time.time() - timestamp) > 300:
return False
# 2. Reconstruct the signed message
message = f"{timestamp}.{body.decode('utf-8')}".encode("utf-8")
# 3. Compute expected signature
expected = hmac.new(
secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
# 4. Compare (constant-time)
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
const crypto = require("crypto");
function verifyWebhook(body, signatureHeader, timestampHeader, secret) {
// 1. Reject stale timestamps (>5 minutes old)
const timestamp = parseInt(timestampHeader, 10);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// 2. Reconstruct the signed message
const message = `${timestamp}.${body}`;
// 3. Compute expected signature
const expected = crypto
.createHmac("sha256", secret)
.update(message, "utf8")
.digest("hex");
// 4. Compare (constant-time)
const received = signatureHeader.replace("sha256=", "");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received)
);
}
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"time"
)
func VerifyWebhook(body []byte, signatureHeader, timestampHeader, secret string) bool {
// 1. Reject stale timestamps (>5 minutes old)
ts, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil {
return false
}
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
// 2. Reconstruct the signed message
message := fmt.Sprintf("%d.%s", ts, string(body))
// 3. Compute expected signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(message))
expected := hex.EncodeToString(mac.Sum(nil))
// 4. Compare (constant-time)
received := signatureHeader[len("sha256="):]
return hmac.Equal([]byte(expected), []byte(received))
}
Replay protection
Always validate the timestamp. Reject deliveries where X-Falara-Timestamp is more than 5 minutes old to prevent replay attacks.
Payload Envelope¶
Every webhook delivery uses the same top-level structure:
{
"delivery_id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8",
"event": "job.completed",
"event_version": 1,
"timestamp": "2026-03-20T12:00:00+00:00",
"data": {
"...": "event-specific fields"
}
}
| Field | Type | Description |
|---|---|---|
delivery_id |
string |
Unique ID for this delivery attempt (UUIDv4). Use for idempotency. |
event |
string |
Event type (see below). |
event_version |
integer |
Schema version for the event payload (currently 1). |
timestamp |
string |
ISO 8601 timestamp when the event was created. |
data |
object |
Event-specific payload. |
Events¶
job.completed¶
Fired when a translation job finishes successfully (status completed or completed_with_blocks).
{
"delivery_id": "d4f5a6b7-c8d9-4e0f-a1b2-c3d4e5f6a7b8",
"event": "job.completed",
"event_version": 1,
"timestamp": "2026-03-20T12:00:00+00:00",
"data": {
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"batch_id": null,
"source_lang": "de",
"target_lang": "en",
"status": "completed",
"has_delivery_notes": false,
"result_url": "https://app.falara.io/v1/jobs/550e8400-.../result",
"download_url": "https://app.falara.io/v1/jobs/550e8400-.../download"
}
}
job.failed¶
Fired when a job fails unrecoverably (status failed or dead).
{
"delivery_id": "...",
"event": "job.failed",
"event_version": 1,
"timestamp": "2026-03-20T12:05:00+00:00",
"data": {
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"batch_id": null,
"source_lang": "de",
"target_lang": "en",
"status": "failed",
"has_delivery_notes": true,
"result_url": "https://app.falara.io/v1/jobs/550e8400-.../result",
"download_url": "https://app.falara.io/v1/jobs/550e8400-.../download"
}
}
job.needs_review¶
Fired when QA score is below threshold after correction loops. The translation is available but may need manual review.
{
"delivery_id": "...",
"event": "job.needs_review",
"event_version": 1,
"timestamp": "2026-03-20T12:10:00+00:00",
"data": {
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"batch_id": "batch_abc123",
"source_lang": "de",
"target_lang": "en",
"status": "needs_review",
"has_delivery_notes": true,
"result_url": "https://app.falara.io/v1/jobs/550e8400-.../result",
"download_url": "https://app.falara.io/v1/jobs/550e8400-.../download"
}
}
batch.completed¶
Fired when all jobs in a batch have reached a terminal status.
{
"delivery_id": "...",
"event": "batch.completed",
"event_version": 1,
"timestamp": "2026-03-20T12:15:00+00:00",
"data": {
"batch_id": "batch_abc123",
"total_jobs": 5,
"completed": 4,
"failed": 0,
"needs_review": 1,
"download_url": "https://app.falara.io/v1/jobs/batch/batch_abc123/download"
}
}
Status to Event Mapping¶
| Job Status | Webhook Event |
|---|---|
completed |
job.completed |
completed_with_blocks |
job.completed |
failed |
job.failed |
dead |
job.failed |
needs_review |
job.needs_review |
queued |
(no event) |
processing |
(no event) |
Delivery¶
Retry Policy¶
Failed deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 30 seconds |
| 2nd retry | 2 minutes |
| 3rd retry | 10 minutes |
| 4th retry | 30 minutes |
| 5th retry | 2 hours |
After 5 retries, the delivery is permanently dropped and logged.
Non-Retryable Status Codes¶
The following HTTP responses from your endpoint are treated as permanent failures (no retry):
| Status Code | Meaning |
|---|---|
400 |
Bad Request |
401 |
Unauthorized |
403 |
Forbidden |
404 |
Not Found |
410 |
Gone |
422 |
Unprocessable Entity |
All other non-2xx responses (including 429 and 5xx) trigger a retry.
Duplicate-Event Protection¶
Each (job_id, event) pair is delivered at most once. Duplicate enqueue calls within 24 hours are silently ignored. Use the delivery_id field for additional idempotency on your end.
Your Endpoint Requirements¶
Your webhook endpoint must:
- Accept POST requests with
Content-Type: application/json. - Return 2xx within 3 seconds. If your processing takes longer, accept the request immediately and process asynchronously.
- Use HTTPS. HTTP endpoints are rejected during configuration.
- Be publicly reachable. URLs resolving to private/internal networks are blocked (SSRF protection).
- Verify the signature using the
X-Falara-SignatureandX-Falara-Timestampheaders.
Idempotent processing
Although Falara delivers each event at most once, network issues could theoretically cause a duplicate. Use delivery_id to deduplicate on your side.
Timeout
If your endpoint does not respond within 3 seconds, the delivery is treated as a failure and will be retried. Keep processing fast or use a job queue.