Outbound
Webhooks
Webhooks are the outbound half of the Sync Users & Subscribers connector. Instead of pushing contacts into Zoye, they let Zoye notify your own server the moment a contact is created, updated, changes stage, or is archived - so your systems stay in sync in real time.
How it works
- You register an endpoint URL on your server and choose which events to receive.
- When a matching event happens in Zoye, we POST a signed JSON payload to that URL.
- Your server verifies the signature, does its work, and responds with a 2xx status to acknowledge.
- If your server is down or returns a non-2xx, we retry with exponential backoff.
Set it up
Webhooks are managed in the app, not with an API key. Open the Sync Users & Subscribers connector, choose Send updates out, then:
- Add the endpoint URL on your server that should receive events.
- Pick the events you care about (or all of them).
- Copy the signing secret (
whsec_...) shown once on creation, and store it on your server. You use it to verify every delivery.
One secret per endpoint
Each endpoint gets its own signing secret, shown only once when you create it. If you lose it, delete the endpoint and add a new one. You can expand any endpoint in the app to see recent deliveries and replay failed ones.
Events
| Event | Fires when |
|---|---|
| contact.created | A new contact (person or company) is added. |
| contact.updated | Any field on a contact changes. |
| contact.stage_changed | A contact moves to a different lifecycle stage. Includes previousStage. |
| contact.archived | A contact is archived (soft-deleted). |
| contact.restored | An archived contact is restored. |
| contact.deleted | A contact is permanently deleted. |
Payload
Every delivery is a JSON object with the event name, an ISO 8601 timestamp, the workspace id, and a data object with the contact. The data shape matches the contact you get back from the REST API.
{
"event": "contact.created",
"timestamp": "2026-06-05T12:34:56.000Z",
"workspace_id": 1624,
"data": {
"id": 1054,
"email": "jordan@northwind.com",
"name": "Jordan Rivera",
"phone": "+15551234567",
"company": "Northwind",
"position": "CTO",
"tags": ["vip"],
"source": "website-form",
"lifecycleStage": "Subscriber",
"archivedAt": null,
"externalId": null,
"createdAt": "2026-06-05T12:34:56.000Z",
"updatedAt": "2026-06-05T12:34:56.000Z"
}
}For contact.stage_changed, the data object also includes previousStage so you can see where the contact moved from:
"data": {
"id": 1054,
"name": "Jordan Rivera",
"lifecycleStage": "Customer",
"previousStage": "Lead"
// ...the rest of the contact fields
}Request headers
| Header | Meaning |
|---|---|
| X-Zoye-Signature | HMAC-SHA256 of the raw request body, prefixed with sha256=. Verify this. |
| X-Zoye-Event | The event type, e.g. contact.created. |
| X-Zoye-Delivery | A unique id for this delivery attempt. Use it for idempotency. |
| User-Agent | Always Zoye-Webhooks/1.0. |
Verify the signature
Always verify the signature before trusting a payload. Compute the HMAC-SHA256 of the raw request body with your endpoint secret, prefix it with sha256=, and compare it to the X-Zoye-Signature header using a constant-time comparison. Use the raw bytes of the body, not a re-serialized object.
Read the raw body
Frameworks that auto-parse JSON can change the bytes (key order, whitespace) and break the signature. Capture the raw body before parsing - for example Express needs express.raw({ type: "application/json" }) on the webhook route.
const crypto = require("crypto");
const express = require("express");
const app = express();
const SECRET = process.env.ZOYE_WEBHOOK_SECRET; // whsec_...
app.post(
"/zoye-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-Zoye-Signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
// ...handle payload.event + payload.data...
res.sendStatus(200);
}
);Retries and delivery
Respond with any 2xx status to acknowledge a delivery. Anything else (or a timeout - we wait up to 15 seconds per attempt) is treated as a failure and retried up to 5 times with exponential backoff:
| Attempt | When |
|---|---|
| 1st retry | 1 minute after the first failure |
| 2nd retry | 5 minutes later |
| 3rd retry | 30 minutes later |
| 4th retry | 2 hours later |
| 5th retry | 12 hours later, then the delivery is marked failed |
You can also open any endpoint in the app to view its recent deliveries (with response status) and manually replay any that failed.
Best practices
- Verify every signature before acting on a payload, and reject anything that does not match.
- Respond fast. Acknowledge with 2xx right away and do slow work (emails, syncs) in a background job, so you never hit the 15-second timeout.
- Be idempotent. A delivery can arrive more than once on retries. Use
X-Zoye-Delivery(or the contact id plus event) to skip duplicates. - Tolerate new fields. We may add fields to
dataover time. Ignore unknown fields rather than failing.
Want the other direction? See No-code embed, REST API, and MCP server for getting contacts into Zoye.