Skip to main content

Webhook Subscriptions

Receive real-time HTTP notifications when document generation events occur. Webhooks eliminate the need to poll for status updates.

Workspace Context

Webhooks are workspace-scoped. Each workspace has its own webhook subscriptions, and webhooks only receive events from their workspace. This ensures proper isolation between projects and environments.

Learn more about Teams & Workspaces.

Overview​

When you create a webhook subscription, Rynko will send HTTP POST requests to your specified URL whenever matching events occur within that workspace.

Supported Events​

EventDescription
document.generatedDocument successfully generated
document.failedDocument generation failed
batch.completedBatch processing completed
batch.failedBatch processing failed

Getting Started​

Create a Webhook Endpoint​

Create an HTTPS endpoint on your server that accepts POST requests:

// Express.js example
app.post('/webhooks/rynko', express.json(), (req, res) => {
const event = req.body;

console.log('Received event:', event.event);
console.log('Job ID:', event.data.jobId);

// Process the event
switch (event.event) {
case 'document.generated':
// Document is ready - save download URL or notify user
console.log('Download URL:', event.data.downloadUrl);
// Access metadata you passed during generation
if (event.data.metadata) {
console.log('Order ID:', event.data.metadata.orderId);
}
break;
case 'document.failed':
// Handle failure - retry or alert
console.log('Error:', event.data.errorMessage);
// Access metadata for correlation
if (event.data.metadata) {
console.log('Failed order:', event.data.metadata.orderId);
}
break;
case 'batch.completed':
// Process batch results
console.log('Documents:', event.data.completedDocuments);
break;
}

// Always respond with 200 OK quickly
res.status(200).send('OK');
});
warning

Your endpoint must respond with 2xx status within 30 seconds, or the delivery will be marked as failed and retried.

Create a Webhook Subscription​

Use the API or dashboard to create a subscription:

const response = await fetch('https://api.rynko.dev/api/v1/webhook-subscriptions', {
method: 'POST',
headers: {
'Authorization': 'Bearer your-api-key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://yourapp.com/webhooks/rynko',
events: ['document.generated', 'document.failed', 'batch.completed'],
description: 'Production webhook for document events'
})
});

const subscription = await response.json();
// Save subscription.secret - you'll need it to verify signatures
console.log('Secret:', subscription.secret);

Via Dashboard:

  1. Go to Settings → Webhooks
  2. Click Create Webhook
  3. Enter your endpoint URL
  4. Select the events you want to receive
  5. Click Create
  6. Copy the signing secret

Verify Webhook Signatures​

Always verify webhook signatures to ensure requests are from Rynko:

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, timestamp, secret) {
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

return signature === `v1=${expectedSignature}`;
}

app.post('/webhooks/rynko', express.json(), (req, res) => {
const signature = req.headers['x-rynko-signature'];
const timestamp = req.headers['x-rynko-timestamp'];

if (!verifyWebhookSignature(req.body, signature, timestamp, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}

// Process verified webhook
// ...

res.status(200).send('OK');
});

Test Your Webhook​

Send a test event to verify your endpoint:

await fetch(`https://api.rynko.dev/api/v1/webhook-subscriptions/${subscriptionId}/test`, {
method: 'POST',
headers: {
'Authorization': 'Bearer your-api-key',
'Content-Type': 'application/json'
}
});

Webhook Payload Format​

Every webhook delivery includes these headers and a JSON body:

Headers​

HeaderDescriptionExample
X-Rynko-SignatureHMAC-SHA256 signaturev1=abc123...
X-Rynko-TimestampUnix timestamp1732723200
X-Rynko-Event-IdUnique event identifierevt_xxx
X-Rynko-Event-TypeEvent typedocument.generated
Content-TypeAlways JSONapplication/json

Body Structure​

{
"event": "document.generated",
"timestamp": "2024-11-27T12:00:00.000Z",
"data": {
"jobId": "550e8400-e29b-41d4-a716-446655440000",
"templateId": "invoice-template",
"format": "pdf",
"status": "completed",
"downloadUrl": "https://storage.rynko.dev/...",
"expiresAt": "2024-11-30T12:00:00.000Z",
"metadata": {
"orderId": "12345"
}
}
}

Event Payloads​

document.generated​

{
"event": "document.generated",
"timestamp": "2024-11-27T12:00:00.000Z",
"data": {
"jobId": "uuid",
"templateId": "invoice-template",
"format": "pdf",
"status": "completed",
"downloadUrl": "https://storage.rynko.dev/...",
"expiresAt": "2024-11-30T12:00:00.000Z",
"generatedAt": "2024-11-27T12:00:00.000Z",
"metadata": {}
}
}

document.failed​

{
"event": "document.failed",
"timestamp": "2024-11-27T12:00:10.000Z",
"data": {
"jobId": "uuid",
"templateId": "invoice-template",
"format": "pdf",
"status": "failed",
"errorMessage": "Missing required variable: customerName",
"errorCode": "MISSING_VARIABLE",
"failedAt": "2024-11-27T12:00:10.000Z",
"metadata": {}
}
}

batch.completed​

{
"event": "batch.completed",
"timestamp": "2024-11-27T12:05:00.000Z",
"data": {
"batchId": "uuid",
"templateId": "invoice-template",
"format": "pdf",
"status": "completed",
"totalDocuments": 100,
"completedDocuments": 98,
"failedDocuments": 2,
"completedAt": "2024-11-27T12:05:00.000Z",
"documents": [
{
"jobId": "uuid1",
"status": "completed",
"downloadUrl": "https://..."
},
{
"jobId": "uuid2",
"status": "failed",
"errorMessage": "Missing variable"
}
]
}
}

batch.failed​

{
"event": "batch.failed",
"timestamp": "2024-11-27T12:05:00.000Z",
"data": {
"batchId": "uuid",
"templateId": "invoice-template",
"format": "pdf",
"status": "failed",
"errorMessage": "Template not found",
"errorCode": "TEMPLATE_NOT_FOUND",
"failedAt": "2024-11-27T12:05:00.000Z"
}
}

API Reference​

List Subscriptions​

GET /api/v1/webhook-subscriptions

curl https://api.rynko.dev/api/v1/webhook-subscriptions \
-H "Authorization: Bearer your-api-key"

Create Subscription​

POST /api/v1/webhook-subscriptions

{
"url": "https://yourapp.com/webhooks/rynko",
"events": ["document.generated", "document.failed"],
"description": "Production webhook",
"enabled": true
}

Response includes the signing secret:

{
"id": "wh_xxxxx",
"url": "https://yourapp.com/webhooks/rynko",
"events": ["document.generated", "document.failed"],
"secret": "whsec_xxxxxxxxxxxxx",
"enabled": true,
"createdAt": "2024-11-27T12:00:00.000Z"
}

Get Subscription​

GET /api/v1/webhook-subscriptions/:id

Update Subscription​

PATCH /api/v1/webhook-subscriptions/:id

{
"events": ["document.generated", "document.failed", "batch.completed"],
"enabled": true
}

Delete Subscription​

DELETE /api/v1/webhook-subscriptions/:id

Send Test Webhook​

POST /api/v1/webhook-subscriptions/:id/test

Sends a test document.generated event to your endpoint.

Get Delivery History​

GET /api/v1/webhook-subscriptions/:id/deliveries

View recent delivery attempts:

{
"deliveries": [
{
"id": "del_xxxxx",
"event": "document.generated",
"status": "success",
"statusCode": 200,
"responseTime": 150,
"attemptedAt": "2024-11-27T12:00:00.000Z"
},
{
"id": "del_yyyyy",
"event": "document.failed",
"status": "failed",
"statusCode": 500,
"error": "Internal Server Error",
"attemptedAt": "2024-11-27T11:55:00.000Z",
"nextRetryAt": "2024-11-27T11:56:00.000Z"
}
]
}

Retry Failed Delivery​

POST /api/v1/webhook-subscriptions/:id/deliveries/:deliveryId/retry

Manually retry a failed delivery.

Signature Verification​

Algorithm​

Rynko uses HMAC-SHA256 for webhook signatures:

  1. Construct the signed payload: {timestamp}.{json_body}
  2. Compute HMAC-SHA256 with your webhook secret
  3. Compare with the signature header (after removing v1= prefix)

Code Examples​

Node.js:

const crypto = require('crypto');

function verifySignature(payload, signature, timestamp, secret) {
const signedPayload = `${timestamp}.${JSON.stringify(payload)}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

return `v1=${expectedSig}` === signature;
}

Python:

import hmac
import hashlib
import json

def verify_signature(payload, signature, timestamp, secret):
signed_payload = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
expected_sig = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
return f"v1={expected_sig}" == signature

Java:

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;

public boolean verifySignature(String payload, String signature, String timestamp, String secret) {
String signedPayload = timestamp + "." + payload;
Mac hmac = Mac.getInstance("HmacSHA256");
hmac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
String expectedSig = "v1=" + HexFormat.of().formatHex(hmac.doFinal(signedPayload.getBytes()));
return expectedSig.equals(signature);
}

Timestamp Validation​

Prevent replay attacks by validating the timestamp:

function isTimestampValid(timestamp, toleranceSeconds = 300) {
const now = Math.floor(Date.now() / 1000);
const eventTime = parseInt(timestamp, 10);
return Math.abs(now - eventTime) <= toleranceSeconds;
}

Retry Policy​

Failed deliveries are automatically retried with exponential backoff:

AttemptDelay
1Immediate
21 minute
35 minutes
4 (final)30 minutes

After 4 failed attempts, the delivery is marked as failed. You can manually retry from the delivery history.

What Counts as Failure​

  • Non-2xx response status
  • Connection timeout (30 seconds)
  • Connection refused
  • SSL/TLS errors

Best Practices​

Respond Quickly​

Return 200 OK as fast as possible. Process events asynchronously:

app.post('/webhooks/rynko', express.json(), async (req, res) => {
// Verify signature first
if (!verifySignature(req.body, req.headers['x-rynko-signature'], req.headers['x-rynko-timestamp'], secret)) {
return res.status(401).send('Invalid signature');
}

// Acknowledge receipt immediately
res.status(200).send('OK');

// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});

async function processWebhookAsync(event) {
switch (event.event) {
case 'document.generated':
await saveDocumentUrl(event.data.jobId, event.data.downloadUrl);
break;
case 'document.failed':
await alertOnFailure(event.data);
break;
case 'batch.completed':
await processBatchResults(event.data);
break;
}
}

Handle Duplicates​

Webhooks may be delivered more than once. Use the X-Rynko-Event-Id header for idempotency:

const processedEvents = new Set(); // Use Redis in production

app.post('/webhooks/rynko', async (req, res) => {
const eventId = req.headers['x-rynko-event-id'];

if (processedEvents.has(eventId)) {
return res.status(200).send('Already processed');
}

// Process event
// ...

processedEvents.add(eventId);
res.status(200).send('OK');
});

Use HTTPS​

Webhook URLs must use HTTPS. Self-signed certificates are not supported.

Rotate Secrets​

If you suspect your webhook secret is compromised, create a new subscription and delete the old one.

Troubleshooting​

Not Receiving Webhooks​

  1. Check subscription is enabled: View subscription in dashboard
  2. Verify URL is accessible: Can Rynko reach your endpoint?
  3. Check firewall rules: Allow incoming connections
  4. Review delivery history: Check for failed deliveries

Signature Verification Failing​

  1. Use raw body: Don't parse JSON before verification
  2. Check secret: Make sure you're using the correct secret
  3. Check timestamp: Ensure your server clock is accurate

Slow Processing​

  1. Respond quickly: Return 200 before processing
  2. Use queue: Process events asynchronously
  3. Batch updates: Don't hit your database for every event