# 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:

```javascript
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:

```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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/upload-post-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/upload-post-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/upload-post-webhooks/examples/fastapi/).

## Common Event Types

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

| Event | Triggered When |
| --- | --- |
| `upload_completed` | An upload finished (result per platform) |
| `social_account_connected` | A social account was connected to a profile |
| `social_account_disconnected` | A social account was disconnected (`reason` included) |
| `social_account_reauth_required` | A connected account needs to be re-authorized |
| `dm_received` | An Instagram DM arrived (opt-in) |
| `comment_received` | A comment arrived on a connected Instagram, TikTok or LinkedIn Company Page account (opt-in; Threads rolling out) |

> **For payload fields**, see [Upload-Post webhooks](https://docs.upload-post.com/api/webhooks)

## Important Headers

| Header | Description |
| --- | --- |
| `X-Upload-Post-Signature` | `sha256=<hex>` HMAC-SHA256, sent when a `webhook_secret` is set |
| `X-Upload-Post-Timestamp` | Unix time (seconds) when the request was signed; part of the signed content |
| `X-Upload-Post-Event` | Event 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

```bash
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

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

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upload-post-webhooks/references/overview.md) - Upload-Post webhook concepts, events, delivery behaviour
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upload-post-webhooks/references/setup.md) - Configure the webhook URL and secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/upload-post-webhooks/references/verification.md) - Signature verification details and gotchas