Skip to Content

Webhooks

EUnifyer webhooks deliver real-time event notifications to an HTTP endpoint you control. Deliveries are signed with HMAC-SHA256, retried on failure, and inspectable from the admin UI.


Quickstart

  1. Create a subscription (API or admin UI at /admin/webhooks).
  2. Store the signing_secret returned once at creation — it is never shown again.
  3. Verify every delivery by recomputing the HMAC over {timestamp}.{body}.
  4. Return any 2xx within 10 seconds to mark the delivery successful.

Managing subscriptions

Admin UI: /admin/webhooks
Auth: admin:access permission or webhooks:manage scope

MethodPathPurpose
GET/api/v1/webhooks/subscriptionsList subscriptions
POST/api/v1/webhooks/subscriptionsCreate (returns signing_secret once)
GET/api/v1/webhooks/subscriptions/{id}Get subscription
PATCH/api/v1/webhooks/subscriptions/{id}Update URL, events, or active state
DELETE/api/v1/webhooks/subscriptions/{id}Delete subscription and delivery history
POST/api/v1/webhooks/subscriptions/{id}/rotate-secretRotate secret (returns new secret once)
GET/api/v1/webhooks/subscriptions/{id}/deliveriesList delivery attempts
POST/api/v1/webhooks/subscriptions/{id}/deliveries/{delivery_id}/replayRe-queue failed delivery

Create a subscription

curl -X POST https://api.eunifyer.com/api/v1/webhooks/subscriptions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/hooks/eunifyer", "description": "Drive changes to our warehouse", "events": ["drive.file.created", "drive.file.updated", "drive.file.deleted"] }'

Response 201:

{ "id": "sub_abc123…", "url": "https://your-app.example.com/hooks/eunifyer", "events": ["drive.file.created", "drive.file.updated", "drive.file.deleted"], "is_active": true, "signing_secret": "a3f8…64-hex-chars…", "created_at": "2026-04-24T10:00:00Z" }

Copy signing_secret now. It is never returned again. If you lose it, rotate via POST /rotate-secret.

Pass "events": [] to subscribe to all events in the catalog.


Delivery headers

Every POST to your endpoint includes:

HeaderExamplePurpose
Content-Typeapplication/json
X-EUnifyer-Eventdrive.file.createdEvent type
X-EUnifyer-Deliverydel_7c9e6…Stable delivery ID — use for dedup
X-EUnifyer-Timestamp1745424000Unix seconds at signature time
X-EUnifyer-Signature-256sha256=abc…HMAC-SHA256 signature

Payload shape

{ "id": "evt_b2f38a07…", "type": "drive.file.created", "created_at": "2026-04-24T10:15:23.456Z", "organization_id": "3a91f…", "data": { "file_id": "file-uuid", "name": "report.pdf" } }
  • id is the event ID — stable across retries and replays. Use it for idempotency on the consumer side.
  • type always matches X-EUnifyer-Event.

Signature verification

The signature is computed as:

sha256(signing_secret, "{timestamp}.{raw_body}")

Where raw_body is the exact request body bytes — no trimming or re-serialization.

Python

import hmac, hashlib, time def verify(secret: str, raw_body: bytes, headers: dict) -> bool: ts = headers["x-eunifyer-timestamp"] sig = headers["x-eunifyer-signature-256"].removeprefix("sha256=") # Reject signatures older than 5 minutes (replay protection) if abs(time.time() - int(ts)) > 300: return False expected = hmac.new( secret.encode(), f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, sig)

Node.js

const crypto = require("crypto"); function verify(secret, rawBody, headers) { const ts = headers["x-eunifyer-timestamp"]; const sig = headers["x-eunifyer-signature-256"].replace(/^sha256=/, ""); if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = crypto .createHmac("sha256", secret) .update(`${ts}.${rawBody.toString()}`) .digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected, "hex"), Buffer.from(sig, "hex") ); }

Express.js example

const express = require("express"); const app = express(); app.post("/hooks/eunifyer", express.raw({ type: "application/json" }), (req, res) => { if (!verify(process.env.WEBHOOK_SECRET, req.body, req.headers)) { return res.status(401).send("Signature invalid"); } const event = JSON.parse(req.body.toString()); console.log("Received:", event.type, event.data); // Acknowledge immediately; process asynchronously res.status(200).send("ok"); processEvent(event).catch(console.error); });

Always read the raw body before parsing. Parsing JSON first and re-serializing will change the byte order and break signature verification.


Event catalog (v1)

Event typedata fieldsFires when
drive.file.createdfile_id, nameTUS upload is confirmed
drive.file.updatedfile_id, nameFile content or metadata changes
drive.file.deletedfile_idFile is permanently deleted
drive.share.createdfile_id, share_id, linkA Drive share link is created
sites.page.publishedsite_id, page_id, slug, titleA Sites page becomes published
organization.user.createduser_id, emailA user is invited via Partner API
organization.user.updateduser_id, changed fieldsA user is updated via Partner API
organization.user.deleteduser_idA user is removed via Partner API
calendar.event.createdevent_id, calendar_id, title, start, endA calendar event is created
calendar.event.updatedevent_id, calendar_id, title, start, end, optional occurrence_dateA calendar event is updated

The v1 catalog is intentionally small. New event types are added only when consumer demand is confirmed.


Retry schedule

Any non-2xx response, timeout, or connection error triggers a retry.

AttemptDelay
InitialImmediate
1st retry30 s
2nd retry5 min
3rd retry30 min
4th retry2 h
5th retry12 h

After six total attempts, the delivery status becomes failed (dead-letter). Replay via the admin UI or POST /deliveries/{id}/replay.

status="skipped" means the subscription was paused when the delivery was attempted. Re-activate and replay to retry.


Pause, resume, and delete

# Pause — queued deliveries become "skipped" PATCH /api/v1/webhooks/subscriptions/{id} { "is_active": false } # Resume PATCH /api/v1/webhooks/subscriptions/{id} { "is_active": true } # Delete subscription and all delivery history DELETE /api/v1/webhooks/subscriptions/{id}

Rotate the signing secret

POST /api/v1/webhooks/subscriptions/{id}/rotate-secret

Returns a new signing_secret immediately — the old secret is invalidated immediately. There is no overlap window.

Best practice: Pause the subscription first (is_active: false), update your consumer, then re-activate.


Consumer best practices

  • Acknowledge fast — return 2xx within 10 seconds. Do heavy processing asynchronously (queue it).
  • Deduplicate — use event.id as the idempotency key. The same event may be delivered more than once if a retry fires after an acknowledged delivery.
  • Verify every delivery — reject requests that fail signature verification or whose timestamp is older than 5 minutes.
  • Store delivery IDs — log X-EUnifyer-Delivery for debugging; include it in support requests.
  • Expect unknown event types — ignore events your consumer doesn’t handle. The catalog grows over time.

What’s not in v1

  • Chat content events — deferred due to E2EE consent and key-management complexity.
  • calendar.event.deleted — will be added when a consumer needs it.
  • CloudEvents envelope — planned for a later version if portability demand emerges.
  • Secret overlap window during rotation.
  • Per-subscription filters beyond event type.