# Guide to Ordinal Webhooks: Features and Best Practices

Ordinal webhooks notify your systems when something happens in your Ordinal workspace: a post is published or fails to publish, someone requests an approval, a comment lands on a draft, a social profile needs reconnecting. If you're mirroring published content into a CRM or data warehouse, routing review requests into Slack, or alerting the social team before scheduled posts start failing, webhooks are how those systems find out in real time instead of polling the API.

This guide covers how Ordinal webhooks work, how to create them in the dashboard or through the API, how to authenticate deliveries that Ordinal does not sign, the gaps in what the platform documents about delivery, and the best practices for production.

## What are Ordinal webhooks?

[Ordinal](https://www.tryordinal.com/) is a social media content planning, approval, and scheduling platform for marketing teams. Teams use it to draft LinkedIn and X posts, group them into campaigns, run approval workflows, and leave comments on drafts before anything goes live. (It is unrelated to Bitcoin Ordinals.)

Ordinal webhooks are HTTP callbacks that notify your application when events occur in a workspace. When a subscribed event fires, Ordinal POSTs a JSON body to your endpoint. Every delivery uses the same envelope: a `type` field naming the event, a `data` object carrying the event-specific payload, and a `createdAt` ISO 8601 timestamp. The `data` object holds a single key whose name depends on the event family: `profile`, `post`, `comment`, `approval`, or `invite`.

## Ordinal webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard (Settings > Integrations > Webhooks) or REST API (`POST https://app.tryordinal.com/api/v1/webhooks`) |
| Authentication | No signature, signing secret, or timestamp header; optional static custom headers set via the webhook's `headers` field |
| Envelope | `{ "type": ..., "data": ..., "createdAt": ... }`, with one key under `data` per event family |
| Event types | 20 topics; each webhook subscribes to at least one via the `topics` array |
| Acknowledgement | Any 2xx status code |
| Event ID | None; no top-level event id and no delivery-id header |
| Management API | `GET`, `POST`, `PATCH`, and `DELETE` on `/webhooks`, authenticated with a workspace API key as a Bearer token |

## Common events

Ordinal event names are the `type` values. Note the mixed separators: some names use underscores inside an otherwise dot-separated string.

| Event | Fires when |
| --- | --- |
| `post.created` | A new post is created |
| `post.scheduled` | A post is scheduled for publishing |
| `post.rescheduled` | A post's scheduled time is changed |
| `post.published` | A post is successfully published to a channel |
| `post.publish_failed` | A post fails to publish (the reason is in `data.post.error`) |
| `post.content.edited` | A post's content is edited (debounced per post, fires about 5 minutes after edits) |
| `post.archived` | A post is moved to trash |
| `post.comment.created` | A post-level comment is added |
| `post.inline_comment.created` | An inline comment is added, including each reply in a thread |
| `post.approval.requested` | Someone requests approval for a post |
| `post.approval.approved` | An approver grants approval for a post |
| `campaign.approval.requested` | Someone requests approval for a campaign |
| `social_profile.reconnect_needed` | A connected profile needs reconnecting (for example, an expired token) |
| `invite.accepted` | A user accepts a workspace invite |

Branch on the `type` field at the top level of the body, then read the matching key under `data`: `data.post` for post events, `data.comment` for comments, `data.approval` for both post and campaign approvals.

> See Ordinal webhook payloads in action. Inspect and replay sample Ordinal webhook payloads in the [Hookdeck Console](https://console.hookdeck.com) — no account or setup required.

## Setting up Ordinal webhooks

In the dashboard, go to Settings > Integrations > Webhooks, set a name and your endpoint URL, choose the event topics, and save. Ordinal does not document whether the dashboard form lets you set custom headers, and custom headers are the only way to authenticate deliveries. If the form has no field for them, add them through the API afterwards.

Via the API, POST to the webhooks endpoint with a name, URL, the topics you want, and a `headers` object carrying a secret you generated yourself (for example with `openssl rand -hex 32`):

```bash
curl -X POST https://app.tryordinal.com/api/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM Sync",
    "url": "https://your-app.com/webhooks/ordinal",
    "topics": ["post.published", "post.publish_failed"],
    "headers": { "X-Webhook-Secret": "YOUR_GENERATED_SECRET" }
  }'

```

The API key is a workspace key you send to Ordinal; it never arrives on a delivery and is not a webhook secret. The header name in `headers` is your choice, not an Ordinal convention. The create response returns only `id`, `name`, `url`, `topics`, and `createdAt`, so call `GET /webhooks/{id}` to confirm the headers were saved. To add headers to a webhook created in the dashboard, or to rotate the secret, send `PATCH /webhooks/{id}` with the full `headers` object.

Ordinal has no test or ping event. To exercise an integration, perform the real action in the app: create a post, request an approval, or add a comment.

For local development, use the [Hookdeck CLI](/docs/cli): `hookdeck listen 3000 ordinal --path /webhooks/ordinal` gives you a public HTTPS URL that forwards to your local server, plus a web UI for inspecting and replaying deliveries, with no account required. Register the generated URL as your webhook endpoint.

## Securing Ordinal webhooks

Ordinal does not sign webhook deliveries. There is no signature header, signing secret, timestamp, or HMAC, so there is nothing to verify cryptographically. Any code that computes an HMAC for Ordinal will reject every genuine delivery.

Authentication comes from the static header you set in the webhook's `headers` field. Ordinal adds that header to every delivery, and your handler compares it against the stored secret. Compare in constant time rather than with `===`, reject the request if the secret is not configured instead of accepting everything, and check the header's type and length before comparing, since `crypto.timingSafeEqual` throws on buffers of different lengths. Because nothing is signed over the body, parsing JSON before the check is fine:

```javascript
const crypto = require("crypto");

// The header name you chose in the webhook's `headers` field; Node lowercases it
const SECRET_HEADER = "x-webhook-secret";

function verifyOrdinalSecret(headers, expected) {
  if (!expected) return false; // fail closed on misconfiguration
  const provided = headers[SECRET_HEADER];
  if (typeof provided !== "string") return false;

  const a = Buffer.from(provided, "utf8");
  const b = Buffer.from(expected, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/ordinal", express.json(), (req, res) => {
  const secret = process.env.ORDINAL_WEBHOOK_SECRET;

  if (!secret) {
    return res.status(500).send("Webhook secret not configured");
  }

  if (!verifyOrdinalSecret(req.headers, secret)) {
    return res.status(401).send("Unauthorized");
  }

  const event = req.body;

  switch (event.type) {
    case "post.published":
      // data.post.postUrl is the live link on the channel (may be null)
      break;
    case "post.publish_failed":
      // data.post.error carries the reason
      break;
    case "post.approval.requested":
      // data.approval, not data.post
      break;
  }

  res.status(200).send("OK");
});

```

Note what this check proves: the caller knows your secret. It does not prove the body is unmodified or that the request is not a replay, because the value is identical on every delivery.

> Make Ordinal webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) checks your secret header at the edge, deduplicates, and durably queues every event with replay for anything that fails.

## Ordinal webhook limitations and pain points

### No signature, only a static header

The Problem: Ordinal deliveries carry no signature. Unless you configure a custom header, a delivery carries no credential at all, and anyone who learns your endpoint URL can POST a fabricated `post.published` or `invite.accepted`. Even with a header configured, a single captured request exposes the secret for every future delivery.

Why It Happens: Ordinal issues no signing secret. The only authentication mechanism it documents is the optional `headers` field, which attaches the same static values to every request.

Workarounds:

* Set a long random secret in `headers` on every webhook, and reject requests without it.
* Serve the endpoint over HTTPS only, and never log the header value.
* Rotate the secret with `PATCH /webhooks/{id}`, accepting both old and new values in your handler during the switch.

How Hookdeck Can Help: Hookdeck's Ordinal source type supports optional API Key verification (a header name and value) and Basic Auth. Configure the API Key check with the same header name and value you set in Ordinal's `headers`, and Hookdeck rejects requests without it before they reach your handler. Basic Auth depends on Ordinal forwarding a custom `Authorization` header, which its docs don't confirm, so the custom header is the safer choice. Hookdeck cannot perform HMAC verification for Ordinal, because no body signature exists. See [sources](/docs/sources) for setup.

### Delivery behavior is undocumented

The Problem: Ordinal documents that your endpoint should return a 2xx to acknowledge receipt, and nothing more. The retry policy, number of attempts, request timeout, and ordering guarantees are not specified, so you cannot plan around them.

Why It Happens: The webhook docs cover the envelope, event types, and management API, but not delivery semantics.

Workarounds:

* Return 200 immediately and defer processing, so slow work never risks a failed delivery.
* Assume a failed delivery may not be retried, and alert on handler errors quickly.
* Reconcile against the Ordinal API periodically for state that must not drift, such as published posts.

How Hookdeck Can Help: Hookdeck ingests and durably queues events ahead of your endpoint, with [automatic retries](/docs/retries) on a schedule you control, [Issues](/docs/issues) that alert you when deliveries fail, and replay for any event. Your own outages stop depending on retry behavior Ordinal doesn't document.

### No event ID to deduplicate on

The Problem: The envelope has no top-level event id, and Ordinal documents no delivery-id header. If the same event arrives twice, nothing in the request marks it as a repeat.

Why It Happens: The envelope is exactly `type`, `data`, and `createdAt`. The ids inside `data` identify the post, comment, or approval, not the event.

Workarounds:

* Derive a key from `type`, the resource id inside `data`, and `createdAt`, and store it on processing.
* Make writes idempotent (upserts keyed on the post id) so a repeat is harmless even if the key check misses.

How Hookdeck Can Help: [Deduplication](/docs/deduplication) can drop repeated deliveries based on the fields you choose, such as `type`, `createdAt`, and the resource id, before they reach your handler.

### Payload shape varies by event family

The Problem: The key under `data` changes with the event. Reading `data.post` on `post.comment.created` or `post.approval.requested` returns `undefined`, because those events use `data.comment` and `data.approval`. Field names also differ between related events: `post.published` has a singular `channel` and `publishedBy`, while `post.created` has a `channels` array and `post.publish_failed` has `createdBy`.

Why It Happens: Ordinal groups events into families (profile, post, comment, approval, invite), and each family has its own payload object.

Workarounds:

* Map each `type` to its `data` key in one place and read through that mapping.
* Use the exact event strings: `post.publish_failed`, not `post.publish.failed`.
* Subscribe each webhook only to the topics you handle.

How Hookdeck Can Help: Register a single Hookdeck source URL for all your topics, then use [filters](/docs/filters) to route events by `type` to different destinations. [Transformations](/docs/transformations) can reshape each family into a consistent structure before delivery.

## Best practices

### Set a secret header on every webhook

A webhook without `headers` arrives with no credential. Generate a random secret, set it when you create the webhook (or `PATCH` it onto a dashboard-created one), confirm it with `GET /webhooks/{id}`, and store it in an environment variable.

### Compare in constant time and fail closed

Use `crypto.timingSafeEqual` with a length check first, return 401 on a missing or wrong header, and reject everything if the secret is unset. A handler that accepts all requests when the environment variable is missing turns a bad deploy into an open endpoint.

### Acknowledge fast, process asynchronously

Ordinal doesn't document its timeout or retry policy, so treat every slow response as a potential lost event. Return 200 as soon as the header checks out and hand the event to a queue or background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Without an event id, build your own key from `type`, the resource id, and `createdAt`, and make processing safe to repeat so a duplicate `post.published` never creates two CRM records. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat edits as current state

`post.content.edited` is debounced per post and fires about 5 minutes after edits, with the latest content for all channels. Overwrite your copy with what it carries rather than trying to apply it as a diff, and don't expect one event per edit.

## Conclusion

Ordinal webhooks cover the lifecycle of social content in a workspace (posts, approvals, comments, connected profiles, and invites) in a consistent `type`, `data`, and `createdAt` envelope. The key decision happens at creation time: Ordinal signs nothing, so the static header you put in `headers` is your endpoint's only protection against forged requests.

Ordinal documents no retry policy or timeout and sends no event id, so a production integration should acknowledge fast, deduplicate on a derived key, and not depend on redelivery. [Hookdeck Event Gateway](/event-gateway) puts header verification, deduplication, and a durable queue with replay in front of your endpoint, so your handlers only ever process authenticated events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Ordinal webhooks reliably in minutes.