Agent skill
Ordinal Webhooks Skill
Receive Ordinal (tryordinal.com) webhooks — the social-media / LinkedIn content planning, approval and scheduling platform for marketing teams. Use when building an Ordinal webhook receiver, because Ordinal does NOT sign its deliveries: there is no HMAC, no signature header, no signing secret and no timestamp, so do not write a signature verifier. The only authentication is a STATIC custom header you configure yourself via the webhook's `headers` field (e.g. `X-Webhook-Secret`), compared in constant time. Use when handling `post.published`, `post.publish_failed`, `post.created`, `post.approval.requested`, `post.comment.created`, `social_profile.reconnect_needed` or `invite.accepted`, unwrapping the `{ type, data, createdAt }` envelope, or managing webhooks via `POST /api/v1/webhooks`. Not Bitcoin Ordinals/inscriptions, not Ordinal Labs.
Install this skill
npx skills add hookdeck/webhook-skills --skill ordinal-webhooks
Ordinal (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 topackage.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:
- Generate a long random secret yourself (e.g.
openssl rand -hex 32). - 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 readx-webhook-secret, overridable viaORDINAL_WEBHOOK_SECRET_HEADER. - Compare the incoming header against
ORDINAL_WEBHOOK_SECRETin constant time and return401on mismatch or absence. - Fail closed: if
ORDINAL_WEBHOOK_SECRETis 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_failedorpost.approval.requested? - Why is
data.postundefined 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)
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/, examples/nextjs/, 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:
{
"type": "post.published",
"data": { "...": "event-specific payload" },
"createdAt": "2025-02-26T14:30:00.000Z"
}
type— the event type stringdata— event-specific payload with one key, whose name depends on the event familycreatedAt— 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 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).
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
# 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
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 - The envelope, all 20 event types, documented payload shapes, what is undocumented
- references/setup.md - Creating a webhook via the dashboard or API, configuring and rotating the custom header secret
- references/verification.md - Why there is nothing to sign, the constant-time header check per framework, fail-closed rules, gotchas