Guides
Webhooks
Get told when something happens instead of polling. Register an endpoint and PostLake POSTs an event to it when a post publishes, fails, or an account connects. Ideal for scheduled posts and async platforms.
Register an endpoint
Give PostLake a URL and the events you care about. You get back a WebhookEndpoint with a secret used to verify deliveries.
curl -X POST https://api.postlake.dev/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/postlake",
"events": ["post.published", "post.failed"]
}'Event types
| Event | Fires when |
|---|---|
post.published | Every target of a post has published. |
post.partial | Some targets published, some failed. |
post.failed | No target published. |
post.processing | A post moved to processing (async platforms). |
post.approved | A human approved the original post. The payload retains id, sourceDraftId, content metadata and the approval decision. |
post.rejected | A human rejected the pending request. Nothing was published. |
account.connected | A social account finished connecting. |
account.disconnected | A social account was disconnected. |
message.received | Someone messaged a connected Facebook or Instagram account. Meta pushes it to us and we pass it straight on. |
comment.received | Someone commented on one of your posts. |
mention.received | Someone mentioned one of your channels. |
Incoming messages, and why they arrive this way
Instagram and Facebook only let you reply within 24 hours of the other person's last message. Polling for new messages tells you roughly when that clock started; Meta telling us tells you exactly. That is why Meta messages come to you as an event, and it is also Meta's own guidance. X and Bluesky messages remain available through the unified inbox API, SDK, and MCP tools, but must be polled because those integrations do not push inbound message events to PostLake.
The payload carries replyBy, which is the moment the network stops accepting a reply, so nothing has to work it out:
{
"id": "evt_9f3c…",
"type": "message.received",
"createdAt": "2026-08-18T14:05:27.000Z",
"data": {
"account": "acc_9b2d…",
"platform": "instagram",
"handle": "postlake_",
"from": "1791754792026896",
"messageId": "aWdfZAG1f…",
"text": "Hey there",
"hasAttachments": false,
"receivedAt": "2026-08-18T14:05:27.000Z",
"replyBy": "2026-08-19T14:05:27.000Z"
}
}Reply with POST /v1/conversations/{id}/messages. A reply a person wrote can pass humanAgent: true, which extends the window to 7 days. Never set it on an automated reply: Meta grants that tag on the condition a human composed the message, and the penalty falls on the connected account.
Your own replies do not come back to you as events, so an agent watching this stream will not answer itself.
Comments and mentions work the same way, and for the same reason. Instagram has no way to poll for mentions at all, and polling for comments means asking every post you have ever made, over and over. So both are pushed: comment.received carries the comment id, the post it is on and the text, which is everything POST /v1/comments/{id}/replies needs to answer it.
The delivery
PostLake POSTs a JSON body with the event type and the affected resource (the full Post for post.* events), so you can act without a follow-up API call.
{
"type": "post.published",
"data": { "id": "post_a1b2c3", "state": "published", "targets": [ … ] }
}Verify the signature
Each delivery attempt has a 10-second timeout. Your endpoint must return a 2xx response directly; redirects are treated as failed deliveries. Failed deliveries are stored for bounded retries. Use GET /v1/webhooks/deliveries to inspect pending or exhausted deliveries and POST /v1/webhooks/deliveries/{id}/replay to retry them.
In Dashboard → Integrations → Webhook setup, Send test event sends a signed webhook.test event to your configured endpoint. HTTP success confirms receipt, not signature verification or successful application processing.
Durable post lifecycle notifications. When a post is approved, rejected, published, partially published, fails or enters processing, its saved state and notifications for subscribed endpoints commit in the same database transaction. Either both are saved or neither is. This covers immediate publishing, approved schedules and async completion. Delivery runs after commit; if the worker stops before sending, the pending notification remains in the outbox for recovery. Other event types are queued before delivery, but are not described as transactionally coupled to their source operation.
Delivery attempts have a 60-second lease, with a 10-second HTTP timeout. An expired lease is recoverable after a worker interruption. Queue sweeps process up to 100 due deliveries with at most ten simultaneous attempts. Successful acknowledgement removes the queue row. Failed attempts use bounded exponential backoff; after five unsuccessful attempts the row remains in the exhausted queue for manual replay. Deleting an endpoint stops its queued notifications from being sent.
In Integrations → Delivery recovery, inspect pending or exhausted notifications and replay one without republishing content or charging credits. The dashboard shows the latest 100 entries per queue. API delivery listing provides the full pending and exhausted queue. Replaying preserves the event ID and generates a fresh delivery signature; an active attempt returns HTTP 409 rather than starting another send.
Events can be repeated or arrive out of order, including if a receiver accepts an event but the worker stops before recording that acknowledgement. Deduplicate by event ID, acknowledge promptly after durable receipt, and read GET /v1/posts/{id} for current state before irreversible actions. Retain the original post ID. A delayed notification must never trigger another publish request. The durable outbox guarantees notification persistence alongside the saved post, not exactly-once processing in the receiving application.
Every delivery carries a postlake-signature header of the form t=<unix-seconds>,v1=<hex>. The v1 value is an HMAC-SHA256 of <t>.<raw-body> keyed with your endpoint secret. Verify it before trusting the payload. This proves the request really came from PostLake and (with a timestamp tolerance) blocks replays.
Verify the raw body, exactly as received. Don't parse and re-serialize the JSON first, or the bytes (and the signature) change.
With the SDK it's one call:
import { verifyWebhookSignature } from "postlake";
// in your handler. Pass the RAW request body
const ok = await verifyWebhookSignature(
endpointSecret, // the secret returned by webhooks.create()
rawBody, // exact bytes received
req.headers["postlake-signature"], // the signature header
{ toleranceSec: 300 }, // reject deliveries older than 5 min (optional)
);
if (!ok) return res.status(400).end();Or without the SDK, in plain Node:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) {
const { t, v1 } = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(v1);
return a.length === b.length && timingSafeEqual(a, b);
}Respond 2xx quickly once verified; failed deliveries are retried with backoff.
Managing endpoints
List your endpoints with GET /v1/webhooks and remove one with DELETE /v1/webhooks/{id}. See the API reference for the exact shapes.