Webhook endpoints

Events, payloads, signature verification, and delivery behavior.

Endpoints

MethodPathDescription
POST/webhooksRegister webhook endpoint
GET/webhooksList webhooks
DELETE/webhooks/:idRemove 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

FieldTypeDescription
urlreqstring (uri)Where deliveries are POSTed. Must be an absolute URI.
eventsreqstring[]At least one name from this exact set. Any other value is rejected with VALIDATION_ERROR.
Valuesrecipient.renderedrecipient.failedcampaign.completedrender.completedrender.failedrender.degradedvideo.viewedvideo.playedvideo.completed

Request

json
POST /webhooks
{
  "url": "https://your-app.com/hooks/outvo",
  "events": ["render.completed", "render.failed", "render.degraded"]
}

Response

json
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

CodeHTTPWhen
VALIDATION_ERROR400Missing url or events, a url that is not a URI, an empty events array, or an event name outside the supported set.
PLAN_LIMIT_REACHED403Your 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

json
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

FieldTypeDescription
idreqstring (uuid)Webhook id.

Events

Campaign path — POST /campaigns/:id/recipients

recipient.renderedrecipient.failedcampaign.completed

Render path — POST /renders

render.completedrender.failedrender.degraded

Video analytics — fired when a viewer interacts with a shared video

video.viewedvideo.playedvideo.completed
The video.* events depend on the host. They are raised by Outvo’s own player. A Vimeo-hosted video sends the recipient to a vimeo.com link instead of an Outvo page, so 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.degraded is a separate event, not a status value on 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

json
{
  "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

json
{
  "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.completed
  • t — 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).
javascript
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

RetriesUp to 3 attempts, exponential backoff (5s, 25s)
Timeout10 seconds per attempt
SuccessAny 2xx response
IdempotencyHandlers should be idempotent — retries may deliver the same event twice

Best practices

  • Return 200 immediately, then process asynchronously.
  • Use metadata / customFields for correlation — they are echoed back verbatim.
  • Always verify signatures in production.