# Ordinal Webhooks

Ordinal ([tryordinal.com](https://www.tryordinal.com/)) is a social-media content
planning, approval and scheduling platform for marketing teams — LinkedIn and X posts,
campaigns, approval workflows and comment threads. App: `https://app.tryordinal.com`.
Docs: `https://docs.tryordinal.com`. REST API base: `https://app.tryordinal.com/api/v1`.

> This is not Bitcoin Ordinals / inscriptions (`ord`), not Ordinal Stats or Ordinal
> Labs, and not an "ordinal numbers" package. There is no official Ordinal npm or pip
> SDK — do not add one to `package.json` / `requirements.txt`.

## There Is No Signature — Do Not Write One

Ordinal does not sign webhook deliveries. Verified against the rendered docs
(introduction, event types, every per-event page, and the Create/Get/Update webhook API
reference): there is no signature header, no signing secret, no `whsec_` key, no
timestamp header, no HMAC, and no Svix / Standard Webhooks headers. The Create webhook
response returns only `id`, `name`, `url`, `topics`, `createdAt` — no secret is ever
issued.

Do not invent `X-Ordinal-Signature`, `Ordinal-Signature`, `webhook-signature`, or any
`crypto.createHmac` / `hmac.new` path. A fabricated verifier rejects every real delivery.

The only authentication mechanism is a static custom header you configure yourself.
The webhook object has an optional `headers` field — documented verbatim as "Optional
custom headers to include in webhook requests" — a JSON object of header name → value
that Ordinal adds to every delivery. The recommended pattern:

1. Generate a long random secret yourself (e.g. `openssl rand -hex 32`).
2. Create or patch the webhook with that secret in `headers`:
  `"headers": { "X-Webhook-Secret": "<your secret>" }`. The header name is your
  choice — it is not an Ordinal-defined header. These examples read
  `x-webhook-secret`, overridable via `ORDINAL_WEBHOOK_SECRET_HEADER`.
3. Compare the incoming header against `ORDINAL_WEBHOOK_SECRET` in constant time and
  return `401` on mismatch or absence.
4. Fail closed: if `ORDINAL_WEBHOOK_SECRET` is unset, reject (`500`) — never accept
  everything silently.

This is a shared-secret channel check, not integrity protection. The value is static
and identical on every delivery, so it is only as good as TLS and your secret hygiene: it
proves the caller knows the secret, not that the body is unmodified. Rotate by
`PATCH /webhooks/{id}` with new `headers`.

Because nothing is signed over the body, there is no raw-body requirement. Parsing
JSON before authenticating is fine here — unlike signed providers (Stripe, Shopify,
GitHub), where a body parser before verification breaks the HMAC. `express.json()` and
`await request.json()` are safe on an Ordinal route.

## When to Use This Skill

* How do I receive Ordinal (tryordinal.com) webhooks?
* How do I verify an Ordinal webhook signature? (You cannot — there is none.)
* How do I authenticate Ordinal webhook deliveries with a custom header secret?
* How do I handle `post.published`, `post.publish_failed` or `post.approval.requested`?
* Why is `data.post` undefined on my Ordinal comment/approval event?
* How do I create an Ordinal webhook subscription via the API?
* How do I deduplicate Ordinal webhooks when there is no event id?

## Verification (core)

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

// Ordinal does NOT sign webhooks: no HMAC, no signature header, no timestamp.
// The ONLY authentication is the STATIC header YOU set in the webhook's
// `headers` field. The header NAME is your choice, not an Ordinal convention.
const SECRET_HEADER = (process.env.ORDINAL_WEBHOOK_SECRET_HEADER || 'x-webhook-secret')
  .toLowerCase();

function verifyOrdinalSecret(headers, expected) {
  // FAIL CLOSED: an unconfigured secret is a rejection, never "accept all".
  if (!expected) return false;
  const provided = headers[SECRET_HEADER]; // Node lowercases inbound header names
  if (typeof provided !== 'string') return false;
  const a = Buffer.from(provided, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual THROWS on length mismatch — compare lengths first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

> For complete handlers with tests, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/ordinal-webhooks/examples/express/),
> [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/ordinal-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/ordinal-webhooks/examples/fastapi/).

Basic Auth alternative. You could instead put
`"Authorization": "Basic <base64(user:pass)>"` in `headers`. The docs do not say
whether `Authorization` (or other reserved header names) may be overridden via `headers`,
so treat the custom `X-` header as the primary path and Basic Auth as an alternative if
Ordinal accepts an `Authorization` custom header.

## The Delivery Envelope

Every delivery is a JSON `POST` with exactly this envelope:

```json
{
  "type": "post.published",
  "data": { "...": "event-specific payload" },
  "createdAt": "2025-02-26T14:30:00.000Z"
}

```

* `type` — the event type string
* `data` — event-specific payload with one key, whose name depends on the event family
* `createdAt` — ISO 8601 time the event was emitted

Respond with any 2xx to acknowledge (docs: "Your endpoint should respond with a `2xx`
status code to acknowledge receipt").

There is no top-level event id and no delivery-id header documented. If you need
dedupe, derive a key yourself — e.g. `type` + the resource id inside `data` + `createdAt`.
That is a suggestion, not a documented idempotency key.

Undocumented — do not fabricate values for these: retry policy / number of attempts,
delivery timeout, ordering guarantees, source IP ranges, `User-Agent`, any
Ordinal-specific delivery headers, and any test/ping event. There is no documented
`ping` event and no IP allowlist.

## Event Types (20 topics)

The `data` key differs per family — reading `data.post` on a comment or approval event
yields `undefined`.

| Event | `data` key | Fires when |
| --- | --- | --- |
| `social_profile.connected` | `data.profile` | A profile is connected to the workspace |
| `social_profile.disconnected` | `data.profile` | A profile is disconnected from the workspace |
| `social_profile.reconnect_needed` | `data.profile` | A profile needs reconnecting (e.g. token expired) |
| `post.created` | `data.post` | A new post is created |
| `post.scheduled` | `data.post` | A post is scheduled for publishing |
| `post.rescheduled` | `data.post` | A post's scheduled time is changed |
| `post.unscheduled` | `data.post` | A post is unscheduled |
| `post.published` | `data.post` | A post is successfully published to a channel |
| `post.publish_failed` | `data.post` | A post fails to publish (see `data.post.error`) |
| `post.archived` | `data.post` | A post is archived (moved to trash) |
| `post.permanently_deleted` | `data.post` | A post is permanently deleted |
| `post.content.edited` | `data.post` | A post's content is edited — debounced per post, fires ~5 minutes after edits, and includes the latest content for all channels |
| `post.comment.created` | `data.comment` | A post-level comment is added |
| `post.inline_comment.created` | `data.comment` | An inline (text-anchored) comment is added — one event per comment, including replies in a thread |
| `post.approval.requested` | `data.approval` | Someone requests approval from users for a post |
| `post.approval.approved` | `data.approval` | An approver grants approval for a post |
| `campaign.approval.requested` | `data.approval` | Someone requests approval for a campaign |
| `campaign.approval.approved` | `data.approval` | An approver grants approval for a campaign |
| `invite.created` | `data.invite` | A user is invited to the workspace — if the invitee already has an account they are added directly and no email is sent |
| `invite.accepted` | `data.invite` | A user accepts a workspace invite |

Mind the mixed separators. `publish_failed`, `reconnect_needed`,
`permanently_deleted` and `inline_comment` use underscores inside a dot-separated
name. Use the exact strings above — `post.publish.failed` and `post.publishFailed` are
both wrong.

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/ordinal-webhooks/references/overview.md) for payload shapes.

## Managing Webhooks via the API

Base `https://app.tryordinal.com/api/v1`, authenticated with your workspace API key
as `Authorization: Bearer <api key>`. That key is for calling Ordinal — it is never
something Ordinal sends to you, and it is not a webhook secret.

| Method | Path |
| --- | --- |
| `GET` | `/webhooks` |
| `POST` | `/webhooks` |
| `GET` | `/webhooks/{id}` |
| `PATCH` | `/webhooks/{id}` (all fields optional) |
| `DELETE` | `/webhooks/{id}` |

`POST` body: `name` (required), `url` (required, uri), `description` (optional), `topics`
(required `string[]`, min 1 — the event types), `headers` (optional object).

```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://example.com/webhooks/ordinal",
       "topics":["post.published","post.archived"],
       "headers":{"X-Webhook-Secret":"<secret>"}}'

```

The Create response omits `description`, `headers` and `createdBy` — call
`GET /webhooks/{id}` for the full object (where `headers` is `object | null`).

## Environment Variables

```bash
# REQUIRED. The random secret YOU generated and configured as a custom header
# value on the Ordinal webhook (`headers`). Ordinal never issues this — you do.
# Unset => the handler fails closed and rejects every delivery.
ORDINAL_WEBHOOK_SECRET=generate_with_openssl_rand_hex_32

# OPTIONAL. The header name you chose in the webhook's `headers` object.
# NOT an Ordinal-defined header. Default: x-webhook-secret
ORDINAL_WEBHOOK_SECRET_HEADER=x-webhook-secret

# OPTIONAL. Workspace API key for CALLING Ordinal's API (managing webhooks,
# fetching post detail). Sent by you as `Authorization: Bearer <key>`.
# This is NOT a webhook signing secret and never arrives inbound.
ORDINAL_API_KEY=your_api_key

```

## Local Development

```bash
npx hookdeck-cli listen 3000 ordinal --path /webhooks/ordinal

```

No account required — the CLI creates a guest account on first run and gives you a public
HTTPS URL plus a web UI for inspecting requests. Use `8000` instead of `3000` for the
FastAPI example.

Hookdeck's `ORDINAL` source type has verification optional and offers the generic
Basic Auth and API Key (header name + value) checks — these validate the static
header you configured in Ordinal's `headers`. Hookdeck cannot do HMAC for Ordinal because
there is no body-dependent signature to verify.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/ordinal-webhooks/references/overview.md) - The envelope, all 20 event types, documented payload shapes, what is undocumented
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/ordinal-webhooks/references/setup.md) - Creating a webhook via the dashboard or API, configuring and rotating the custom header secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/ordinal-webhooks/references/verification.md) - Why there is nothing to sign, the constant-time header check per framework, fail-closed rules, gotchas