# Guide to Upload-Post Webhooks: Features and Best Practices

Upload-Post is an API for publishing videos, photos and text to many social networks with one request. Its webhooks tell your application when an upload has finished on each platform, when a connected social account is disconnected or needs re-authorising, and, if you opt in, when someone sends a DM or leaves a comment on a connected account. They replace polling the upload status endpoint.

This guide covers how Upload-Post webhooks are configured and signed, the events you will receive, the single-attempt delivery model, and the practices that keep an Upload-Post receiver from missing results.

## What are Upload-Post webhooks?

Upload-Post sends notifications through several channels, including Telegram, and a webhook is the one that reaches your own server. You set one webhook URL for the account and choose which events it receives. Agencies and white-label integrators can also set a separate URL for each profile, which fires in addition to the account webhook.

Each event is a JSON `POST` with these headers:

| Header | Value |
| --- | --- |
| `X-Upload-Post-Signature` | `sha256=<hex HMAC-SHA256>`, present whenever the account has a webhook secret |
| `X-Upload-Post-Timestamp` | Unix time in seconds when the request was signed |
| `X-Upload-Post-Event` | The event name, such as `upload_completed` |
| `X-Upload-Post-Delivery` | A unique ID for this delivery |
| `User-Agent` | `Upload-Post-Webhooks/1.0` |

The signature covers the timestamp, a full stop and the raw body. The secret starts with `whsec_`, which looks like a Standard Webhooks secret, but Upload-Post uses the whole string, prefix included, as a plain HMAC key. Standard Webhooks and Svix libraries won't verify it.

## Upload-Post webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Notifications**, or `POST /api/uploadposts/users/notifications` |
| Per-profile webhooks | `/api/uploadposts/users/profile-webhook`, signed with the account secret |
| Subscription | `webhook_events` object; all events by default except DMs and comments |
| Method | `POST`, JSON body |
| Signature header | `X-Upload-Post-Signature`, `sha256=` followed by lowercase hex |
| Timestamp header | `X-Upload-Post-Timestamp`, Unix seconds |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | `{X-Upload-Post-Timestamp}.{raw body}` |
| Secret | `whsec_...`, created when a webhook URL is first saved, used whole as UTF-8 |
| Rotation | `POST /api/uploadposts/users/webhook-secret`, takes effect immediately |
| Timeout | 10 seconds |
| Retries | None. Each event is sent once |
| Circuit breaker | After 5 consecutive failures the channel pauses for 30 minutes |

## Common events

| Event | Fires when |
| --- | --- |
| `upload_completed` | An upload finishes on a platform, whether it succeeded or failed |
| `social_account_connected` | A social account is connected or reconnected |
| `social_account_disconnected` | A social account is disconnected, by the user or automatically |
| `social_account_reauth_required` | A social account's token can no longer be refreshed |
| `dm_received` | Someone sends a DM to a connected Instagram account. Opt-in |
| `comment_received` | Someone comments on a post from a connected Instagram, TikTok or LinkedIn Company Page account, with Threads rolling out. Opt-in |

`dm_received` and `comment_received` arrive only if you list them explicitly in `webhook_events`. Leaving `webhook_events` empty turns on the other four, not these. They go only to webhooks, never to Telegram, Slack or WhatsApp.

Here is Upload-Post's `upload_completed` example:

```json
{
  "event": "upload_completed",
  "request_id": "f7e8d9c0b1a2...",
  "job_id": "a1b2c3d4e5f6...",
  "external_id": "cms-post-8841",
  "user_email": "user@example.com",
  "profile_username": "your_profile_username",
  "platform": "instagram",
  "media_type": "video",
  "title": "My Awesome Video",
  "caption": "Check this out! #cool",
  "result": {
    "success": true,
    "url": "https://www.instagram.com/p/C1234567890/",
    "publish_id": "17987654321098765",
    "post_id": "17987654321098765",
    "error": null
  },
  "created_at": "2024-03-15T14:30:00.000000"
}

```

* `event` names the event. Route on it, or on the `X-Upload-Post-Event` header.
* `request_id` matches the upload request, and `job_id` and `external_id` carry your own identifiers when you supplied them.
* `platform` says which network the result is for. Each event carries a single platform and a single `result`.
* `result.success` is `false` for a failed upload, with the reason in `result.error`.

Account events carry `platform`, `account_name`, `status` and, for disconnections and re-authorisation, a `reason` such as `manual_disconnect`, `account_blocked` or `token_refresh_threshold_exceeded`. Comment events share one shape across networks, but some fields differ: TikTok and LinkedIn give `timestamp` in milliseconds, while Threads gives ISO 8601.

## Setting up Upload-Post webhooks

1. In the Upload-Post dashboard, open **Notifications**, turn on the **Webhook** channel and enter your URL, for example `https://example.com/webhooks/upload-post`.
2. Choose the events. Tick `dm_received` and `comment_received` only if you want them.
3. Save. Upload-Post creates the signing secret the first time a webhook URL is saved.
4. Copy the secret from **Notifications > Signing secret**, including the `whsec_` prefix, and store it server-side, for example as `UPLOAD_POST_WEBHOOK_SECRET`.

To do the same with the API:

```bash
curl -X POST https://app.upload-post.com/api/uploadposts/users/notifications \
  -H "Authorization: Apikey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channels": { "webhook": true },
    "webhook_url": "https://example.com/webhooks/upload-post",
    "webhook_events": {
      "upload_completed": true,
      "social_account_disconnected": true,
      "social_account_reauth_required": true
    }
  }'

```

The response includes `webhook_secret`, and `GET` on the same endpoint returns it later.

If you use Hookdeck, create a source with the Upload-Post source type and use the Hookdeck source URL as the webhook URL. Hookdeck's setup notes for this source say: "Paste the whole secret, including the whsec_ prefix. Upload-Post uses it as a plain HMAC key." The source accepts `POST` only, which is the method Upload-Post uses.

## Securing Upload-Post webhooks

To verify a delivery, read `X-Upload-Post-Timestamp` and `X-Upload-Post-Signature`, and strip `sha256=` from the signature. Build the signed content from the timestamp header exactly as received, a `.` and the raw request body. Compute an HMAC-SHA256 with the whole secret as the key, encode it as hex and compare it with the header in constant time.

Upload-Post's own samples reject timestamps older than 300 seconds with a `400`. In the code below that check is optional and off by default, for a reason covered in the next section.

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

function verifyUploadPostSignature(rawBody, timestamp, signatureHeader, secret, toleranceSeconds = 0) {
  if (!timestamp || !signatureHeader || !secret) return false;

  const signature = signatureHeader.replace(/^sha256=/i, '');
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestamp)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  }

  // The whole secret, whsec_ prefix included, is the key.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp, 'latin1')
    .update('.')
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(signature.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/upload-post', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyUploadPostSignature(
    req.body,
    req.get('x-upload-post-timestamp'),
    req.get('x-upload-post-signature'),
    process.env.UPLOAD_POST_WEBHOOK_SECRET
  );
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Queue the event, then answer well inside 10 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_upload_post_signature(raw_body: bytes, timestamp, signature_header, secret: str,
                                 tolerance_seconds: int = 0) -> bool:
    if not timestamp or not signature_header or not secret:
        return False
    signature = re.sub(r"^sha256=", "", signature_header, count=1, flags=re.IGNORECASE)
    if not HEX_64.fullmatch(signature):
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0:
        if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
            return False

    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_upload_post_signature(raw_body, request.headers.get("x-upload-post-timestamp"),
#                              request.headers.get("x-upload-post-signature"),
#                              os.environ["UPLOAD_POST_WEBHOOK_SECRET"])

```

Hookdeck's Upload-Post source type checks the same signature: HMAC-SHA256 over `{X-Upload-Post-Timestamp}.{body}`, hex, keyed with the whole secret. It applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.

> **Make Upload-Post webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Upload-Post-Signature` header on every delivery, stores each upload result and account event, and delivers it to your service with retries you control.

## Upload-Post webhook limitations and pain points

### One attempt, no retries

**The Problem:** An upload result that arrives during a deploy is gone for good, and your system never learns that the post went live, or that it failed.

**Why It Happens:** Upload-Post sends each event once. If your endpoint takes longer than 10 seconds, refuses the connection or answers with a non-`2xx`, "that event is not retried or queued for later". Upload-Post describes webhooks as a fast path and tells you to keep a fallback.

**Workarounds:**

* Answer with a `2xx` as soon as the signature checks out, and do the work afterwards.
* Reconcile on a schedule with the Upload Status or Upload History endpoints, matching on `request_id`.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives and stores it, so the one attempt Upload-Post makes is the one Hookdeck receives. Hookdeck then delivers to your service with [retries](/docs/retries) on your schedule, and you can replay anything that failed.

### The circuit breaker drops events

**The Problem:** A run of errors is followed by half an hour of silence, even after your endpoint recovers.

**Why It Happens:** After 5 consecutive failed deliveries, Upload-Post pauses the webhook channel for 30 minutes. "Deliveries skipped while paused are not retried later." A short outage becomes a 30-minute gap.

**Workarounds:**

* Keep the endpoint fast and always available, and process asynchronously.
* After any pause, reconcile the missing window through Upload History.

**How Hookdeck Can Help:** Because Hookdeck answers each delivery when it arrives, failures in your own service don't count towards Upload-Post's five.

### The five-minute window and replays

**The Problem:** A delivery that you replay from Hookdeck is rejected by your own code, even though its signature is valid.

**Why It Happens:** Upload-Post's samples reject timestamps older than 300 seconds. Hookdeck retries and replays resend the original headers, so they carry the timestamp of the first send.

**Workarounds:**

* Leave the timestamp check off and rely on idempotency instead.
* If you turn it on, choose a window longer than the longest delay you allow between the original send and a retry or replay.

**How Hookdeck Can Help:** Hookdeck applies no timestamp tolerance, so retried and replayed deliveries still pass verification at the source.

### Rotation takes effect immediately

**The Problem:** Deliveries fail verification right after you rotate the secret.

**Why It Happens:** Rotating with `POST /api/uploadposts/users/webhook-secret` switches the secret at once, and Upload-Post has one secret for the account and all its profile webhooks. Any receiver still on the old secret fails until it is updated, and with no retries those events are not resent.

**Workarounds:**

* Rotate at a quiet time, and update every receiver straight away.
* Accept both the old and new secret for a short period during the switch, as Upload-Post suggests.
* Reconcile the rotation window through Upload History.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives in one Hookdeck source rather than in each receiving service, so there is only one place to update.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Upload-Post sent. In Express, use `express.raw({ type: '*/*' })` on the webhook route. In a Next.js route handler, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, read `await request.body()`. Parse JSON only after verification passes.

### Use the whole secret

Keep the `whsec_` prefix and don't base64-decode anything. If verification fails on every delivery, check this first.

### Deduplicate on your own identifiers

`X-Upload-Post-Delivery` is unique per delivery and is for logging. For idempotency, Upload-Post points to your own identifiers, such as `result.post_id` or `result.publish_id` for successful posts. For failed uploads, a key built from `request_id` and `platform` does the same job. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Upload-Post waits 10 seconds and never tries again. Verify, persist the event, return `200`, and update your records afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Act on account events

`social_account_reauth_required` and `social_account_disconnected` mean future uploads to that account will fail. Prompt the user to reconnect when they arrive, rather than waiting for the next failed upload.

## Conclusion

Upload-Post signs each delivery with a `sha256=` hex HMAC of the timestamp and raw body, keyed with the whole `whsec_` secret. It sends each event once, with a 10-second timeout and a circuit breaker that drops events while paused, so a fast, always-available endpoint and a reconciliation job matter more here than with most providers.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Upload-Post signature and holds each event until your service has processed it, so an upload result isn't lost to a deploy or a slow handler.

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