Agent skill

Upload-Post Webhooks Skill

Receive and verify Upload-Post webhooks (upload-post.com, the API for publishing to many social networks). Use when setting up an Upload-Post webhook handler, debugging X-Upload-Post-Signature verification, or handling upload_completed, social_account_connected, social_account_disconnected, social_account_reauth_required, dm_received and comment_received events. The signature is a hex HMAC-SHA256 over the X-Upload-Post-Timestamp header, a dot, and the raw body. It is not Standard Webhooks.

Install this skill

npx skills add hookdeck/webhook-skills --skill upload-post-webhooks


Upload-Post (upload-post.com) is an API for publishing content to many social networks. Its webhooks report upload results and social-account connection changes, plus opt-in Instagram DMs and comments (Instagram, TikTok, LinkedIn Company Pages).

When to Use This Skill

  • How do I receive Upload-Post webhooks?
  • How do I verify the X-Upload-Post-Signature header?
  • How do I handle upload_completed, social_account_reauth_required or comment_received events?
  • Why does my Upload-Post webhook signature verification fail?
  • Does Upload-Post use Standard Webhooks? (No.)

Verification (core)

Upload-Post sends two headers: X-Upload-Post-Signature: sha256=<hex> (present whenever the account has a webhook_secret) and X-Upload-Post-Timestamp (Unix seconds). The signature is the lowercase hex HMAC-SHA256 of <timestamp>.<raw body>.

Paste the whole secret, including the whsec_ prefix. Upload-Post uses it as a plain HMAC key.

The key is the secret's UTF-8 bytes, used whole: nothing is base64-decoded, so do not use the standardwebhooks or svix libraries. Build the signed message as bytes (the timestamp header value as latin1, then ., then the raw body) and never decode and re-encode the body.

Node:

const crypto = require('crypto');

function verify(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;
  const hex = signature.startsWith('sha256=') ? signature.slice(7) : signature;
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) return false;
  const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(hex.toLowerCase(), 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

Python:

import hashlib, hmac, re

def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False
    digest = signature[7:] if signature.startswith("sha256=") else signature
    if not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
        return False
    signed = b"".join([timestamp.encode("latin-1"), b".", raw_body])
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(digest.lower()), expected)

A missing or empty header, a malformed value or a mismatch is a 401. In Express use express.raw({ type: '*/*' }), in Next.js await request.arrayBuffer(), in FastAPI await request.body(), and parse the JSON only after the check passes.

Timestamp window (opt-in): the examples do not reject old timestamps by default. Hookdeck retries and replays resend the original headers, and Hookdeck itself applies no tolerance, so a fixed window would reject legitimate retries. Set UPLOAD_POST_WEBHOOK_TOLERANCE_SECONDS (unset or 0 = off) to reject deliveries whose X-Upload-Post-Timestamp is further than that many seconds from now.

Deliveries made before a secret existed carry no signature header and fail verification.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

Dispatch on the body's event field. The examples handle these and log the rest.

EventTriggered When
upload_completedAn upload finished (result per platform)
social_account_connectedA social account was connected to a profile
social_account_disconnectedA social account was disconnected (reason included)
social_account_reauth_requiredA connected account needs to be re-authorized
dm_receivedAn Instagram DM arrived (opt-in)
comment_receivedA comment arrived on a connected Instagram, TikTok or LinkedIn Company Page account (opt-in; Threads rolling out)

For payload fields, see Upload-Post webhooks

Important Headers

HeaderDescription
X-Upload-Post-Signaturesha256=<hex> HMAC-SHA256, sent when a webhook_secret is set
X-Upload-Post-TimestampUnix time (seconds) when the request was signed; part of the signed content
X-Upload-Post-EventEvent name (same as the body's event)

Delivery

One attempt per event with a 10 s timeout. A refused connection or non-2xx response loses the event, and after 5 consecutive failures the channel pauses for 30 minutes. Respond quickly and process asynchronously.

Environment Variables

UPLOAD_POST_WEBHOOK_SECRET=whsec_your_webhook_secret   # whole secret, whsec_ prefix included
UPLOAD_POST_WEBHOOK_TOLERANCE_SECONDS=0                # optional; 0 or unset = no timestamp window

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 upload-post --path /webhooks/upload-post

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →