Webhook endpoints
Events, payloads, signature verification, and delivery behavior.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /webhooks | Register webhook endpoint |
| GET | /webhooks | List webhooks |
| DELETE | /webhooks/:id | Remove webhook |
Reference
POST/webhooks
Register an endpoint and subscribe it to events. The signing secret is returned exactly once, here.
Success: 201 Created
Store signingSecret now — every later read returns it masked. It is what you verify deliveries against; see Signature verification.
Body
| Field | Type | Description |
|---|---|---|
| urlreq | string (uri) | Where deliveries are POSTed. Must be an absolute URI. |
| eventsreq | string[] | At least one name from this exact set. Any other value is rejected with VALIDATION_ERROR. Values recipient.renderedrecipient.failedcampaign.completedrender.completedrender.failedrender.degradedvideo.viewedvideo.playedvideo.completed |
Request
POST /webhooks
{
"url": "https://your-app.com/hooks/outvo",
"events": ["render.completed", "render.failed", "render.degraded"]
}Response
201 Created
{
"data": {
"id": "5a4b3c2d-...",
"userId": "...",
"url": "https://your-app.com/hooks/outvo",
"events": ["render.completed", "render.failed", "render.degraded"],
"active": true,
"signingSecret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"createdAt": "2026-08-13T12:00:00.000Z"
}
}Errors
| Code | HTTP | When |
|---|---|---|
| VALIDATION_ERROR | 400 | Missing url or events, a url that is not a URI, an empty events array, or an event name outside the supported set. |
| PLAN_LIMIT_REACHED | 403 | Your plan does not include webhooks. Upgrade to Pro or higher. |
GET/webhooks
List your registered webhooks, newest first.
Success: 200 OK
signingSecret is masked to its last four characters here — enough to tell two registrations apart, not enough to sign anything. If you lost the real secret, delete the webhook and register it again.
Response
200 OK
{
"data": [
{
"id": "5a4b3c2d-...",
"userId": "...",
"url": "https://your-app.com/hooks/outvo",
"events": ["render.completed", "render.failed"],
"active": true,
"signingSecret": "whsec_****************************abcd",
"createdAt": "2026-08-13T12:00:00.000Z"
}
]
}DELETE/webhooks/:id
Remove a webhook. Deliveries stop immediately.
Success: 204 No Content
Deleting is idempotent and does not 404: an unknown or already-deleted id also returns 204. Confirm removal with GET /webhooks rather than by the status code.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Webhook id. |
Events
Campaign path — POST /campaigns/:id/recipients
Render path — POST /renders
Video analytics — fired when a viewer interacts with a shared video
video.viewed and video.completed never fire for it, and video.played is derived from Vimeo’s play counter moving between polls — we have observed that counter lagging real plays by 20–90 minutes and cannot bound the delay. Do not read the absence of these events on a Vimeo-hosted campaign as evidence about the viewer.render.completed. Degraded renders are never billed — filter on the event name, not on a field inside the payload, to reconcile spend against deliveries.Payload examples
render.completed
{
"event": "render.completed",
"timestamp": "2026-08-05T12:05:00.000Z",
"data": {
"renderId": "rnd_abc123",
"status": "completed",
"outputUrl": "https://share.outvo.io/v/abc123",
"metadata": { "row": 1, "salesforceId": "003..." }
}
}recipient.rendered
{
"event": "recipient.rendered",
"timestamp": "2026-08-05T12:05:00.000Z",
"data": {
"campaignId": "cmp_abc123",
"recipientId": "rcp_def456",
"status": "completed",
"outputUrl": "https://share.outvo.io/v/abc123",
"metadata": { "your": "customFields" },
"email": "sarah@acme.com",
"externalId": "lead_8813",
"thumbnailUrl": "https://share.outvo.io/v/abc123/thumb.gif",
"expiresAt": "2026-09-04T12:05:00.000Z"
}
}Signature verification
Every delivery includes these headers:
X-Outvo-Signature: t=1753579200,v1=5f2c4a...91b3
X-Outvo-Event: render.completedt— signing timestamp, Unix seconds.v1—HMAC-SHA256(signingSecret, "{t}.{rawBody}")hex-encoded.- The signed payload is timestamp +
"."+ raw body — not the body alone. - Verify against the raw request body, before any JSON parsing.
- Reject deliveries older than ~5 minutes (replay protection).
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header || '').split(',').map(kv => kv.split('=', 2)).filter(kv => kv.length === 2)
);
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Delivery behavior
| Retries | Up to 3 attempts, exponential backoff (5s, 25s) |
| Timeout | 10 seconds per attempt |
| Success | Any 2xx response |
| Idempotency | Handlers should be idempotent — retries may deliver the same event twice |
Best practices
- Return
200immediately, then process asynchronously. - Use
metadata/customFieldsfor correlation — they are echoed back verbatim. - Always verify signatures in production.
