Documentation menu

Webhooks

Webhooks tell your own systems what happened to a post without polling. Add an HTTPS endpoint under Settings → Webhooks, pick the events you want, and TimeToPost sends a signed JSON POST for each one. Org owners and admins manage endpoints; every member can read the delivery history.

Events

  • post.scheduled: a post was queued for a future time.
  • post.published: the post went out on at least one platform. data.partial is true when some platforms failed and will be retried.
  • post.failed: the post failed for good (retries exhausted, skipped as overdue, or abandoned mid-publish).
  • draft.approved: an engine/API draft was approved and scheduled.
  • draft.rejected: a draft was rejected.

You can also send a webhook.test event from the dashboard or the API at any time.

Payload

POST https://your.app/hooks/timetopost
{
  "id": "evt_3f9c1b7a5d2e48c0a1b2c3d4e5f60718",
  "type": "post.published",
  "created": "2026-10-24T18:30:02.114Z",
  "orgId": "org_...",
  "data": {
    "postId": "post_...",
    "status": "published",
    "partial": false,
    "platforms": ["twitter"],
    "publishedAt": "2026-10-24T18:30:01.870Z"
  }
}

Headers on every request:

Content-Type: application/json
User-Agent: TimeToPost-Webhooks/1.0
TimeToPost-Event: post.published
TimeToPost-Event-Id: evt_3f9c...          # idempotency key, stable across retries
TimeToPost-Delivery: <delivery id>
TimeToPost-Attempt: 1                     # 1..8
TimeToPost-Signature: t=1792866602,v1=<hex hmac-sha256>

Verify the signature

The signature is HMAC-SHA256(secret, "<t>.<raw body>"), hex encoded. Verify it against the raw request body, compare in constant time, and reject timestamps more than 5 minutes old to block replays. Your signing secret (whsec_...) is shown once when you create or rotate an endpoint.

Node.js
import crypto from 'node:crypto';

// rawBody MUST be the exact bytes you received (e.g. express.raw), not re-serialized JSON.
export function verifyTimeToPost(secret, signatureHeader, rawBody, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)])
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay guard
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  // The header can carry more than one v1= value; accept if any matches.
  return signatureHeader
    .split(',')
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const got = Buffer.from(p.slice(3), 'hex');
      return got.length === expected.length && crypto.timingSafeEqual(got, expected);
    });
}
Python
import hmac, hashlib, time

def verify_timetopost(secret: str, signature_header: str, raw_body: bytes, tolerance=300) -> bool:
    parts = [p.split("=", 1) for p in signature_header.split(",")]
    t = next((int(v) for k, v in parts if k == "t"), None)
    if t is None or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(v, expected) for k, v in parts if k == "v1")

Delivery, retries and idempotency

  • Respond with any 2xx within 10 seconds. Anything else, a timeout or a connection error counts as a failure. Redirects are not followed.
  • Failed deliveries retry up to 8 attempts total, waiting roughly 30s, 1m, 2m, 4m, 8m, 16m, 32m (±20% jitter).
  • An event can arrive more than once. Deduplicate on TimeToPost-Event-Id (the payload id); the same logical event always has the same id.
  • Ordering is not guaranteed. Use created and fetch current state from the API when it matters.
  • After 10 consecutive deliveries fail permanently, the endpoint is auto-disabled and the dashboard shows why. Re-enable it once fixed.
  • Delivery history is kept for 30 days.

Endpoint requirements

URLs must be https on port 443 and resolve to a public address. Private, loopback, link-local and cloud-metadata addresses are refused, the address is checked at delivery time (not just when you save it) and the connection is pinned to the checked address.

API

GET/api/webhooks

POST/api/webhooks

curl
curl -X POST https://timetopost.co/api/webhooks \
  -H "Authorization: Bearer $TTP_TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://your.app/hooks/timetopost","events":["post.published","post.failed"]}'
# { "success": true, "endpoint": { "id": "...", ... }, "secret": "whsec_..." }  <- secret shown once

PATCH /api/webhooks/:id updates an endpoint with any of url, events, description, enabled.

DELETE/api/webhooks/:id

POST/api/webhooks/:id/rotate-secret

POST/api/webhooks/:id/test

GET/api/webhooks/:id/deliveries