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
- Create a subscription (API or admin UI at
/admin/webhooks). - Store the
signing_secretreturned once at creation — it is never shown again. - Verify every delivery by recomputing the HMAC over
{timestamp}.{body}. - Return any
2xxwithin 10 seconds to mark the delivery successful.
Managing subscriptions
Admin UI: /admin/webhooks
Auth: admin:access permission or webhooks:manage scope
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/webhooks/subscriptions | List subscriptions |
POST | /api/v1/webhooks/subscriptions | Create (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-secret | Rotate secret (returns new secret once) |
GET | /api/v1/webhooks/subscriptions/{id}/deliveries | List delivery attempts |
POST | /api/v1/webhooks/subscriptions/{id}/deliveries/{delivery_id}/replay | Re-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:
| Header | Example | Purpose |
|---|---|---|
Content-Type | application/json | — |
X-EUnifyer-Event | drive.file.created | Event type |
X-EUnifyer-Delivery | del_7c9e6… | Stable delivery ID — use for dedup |
X-EUnifyer-Timestamp | 1745424000 | Unix seconds at signature time |
X-EUnifyer-Signature-256 | sha256=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"
}
}idis the event ID — stable across retries and replays. Use it for idempotency on the consumer side.typealways matchesX-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 type | data fields | Fires when |
|---|---|---|
drive.file.created | file_id, name | TUS upload is confirmed |
drive.file.updated | file_id, name | File content or metadata changes |
drive.file.deleted | file_id | File is permanently deleted |
drive.share.created | file_id, share_id, link | A Drive share link is created |
sites.page.published | site_id, page_id, slug, title | A Sites page becomes published |
organization.user.created | user_id, email | A user is invited via Partner API |
organization.user.updated | user_id, changed fields | A user is updated via Partner API |
organization.user.deleted | user_id | A user is removed via Partner API |
calendar.event.created | event_id, calendar_id, title, start, end | A calendar event is created |
calendar.event.updated | event_id, calendar_id, title, start, end, optional occurrence_date | A 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.
| Attempt | Delay |
|---|---|
| Initial | Immediate |
| 1st retry | 30 s |
| 2nd retry | 5 min |
| 3rd retry | 30 min |
| 4th retry | 2 h |
| 5th retry | 12 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-secretReturns 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
2xxwithin 10 seconds. Do heavy processing asynchronously (queue it). - Deduplicate — use
event.idas 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-Deliveryfor 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.