# Synup Webhooks

Source: https://developer.synup.com/webhooks

Synup can notify your systems in near-real-time when things happen in your account — a listing gets connected, a review campaign is sent, a social post publishes, an AI post idea is generated, and more. When an event occurs, Synup sends an HTTP `POST` with a JSON body to a URL you configure. Every delivery is cryptographically signed so you can verify it genuinely came from Synup.

This guide covers how to set webhooks up on your side, how signing and endpoint verification work (and why), and the shape of every delivery. The exact payload of each event is documented under the **Webhooks** section.

## Quick start

1. **Stand up an HTTPS endpoint** on your server that accepts `POST` requests (publicly reachable, valid TLS certificate).
2. In Synup, go to **Settings → Notifications → Configure Webhooks** and set your **Webhooks URL**.
3. Click **Generate** to create your **Signing secret**. Copy it and store it securely on your server — you'll use it to (a) verify incoming signatures and (b) answer the verification challenge.
4. Implement two things in your endpoint: a **signature check** on every request, and a **challenge response** for the one-time verification handshake.
5. Click **Verify endpoint** in Synup. Synup sends a signed challenge; your endpoint proves it holds the secret. On success the status turns verified and **deliveries begin**.
6. That's it — you'll receive every event for the account. There is no per-event subscription: if webhooks are enabled and your endpoint is verified, all events deliver.

> **Delivery is gated on four things:** the webhooks feature is enabled on your account, a Webhooks URL is set, a signing secret has been generated, and your endpoint has passed verification. If any is missing, events are not delivered.

## Configuring your endpoint

Your Webhooks URL must be:

- **HTTPS** — plain HTTP is rejected.
- **Publicly resolvable** — the host must resolve to a public IP. Internal/private addresses (loopback, RFC-1918, link-local, cloud metadata IPs, etc.) are refused.
- **Fast** — respond within **10 seconds**. Do the minimum synchronously (verify the signature, enqueue the payload) and process asynchronously. A slow endpoint is treated as a failed delivery.
- **Idempotent-friendly** — you may occasionally receive the same event more than once. De-duplicate on the IDs inside `data` (they are stable).

You configure a **single** Webhooks URL per account; all event types are delivered to it.

## The signing secret

A per-account secret string you generate in **Settings → Notifications**. It is the shared key between Synup and your server. Because your endpoint is a public URL, the signing secret lets you **prove a request actually came from Synup** and **was not tampered with in transit**. The secret itself is never transmitted — every delivery instead carries an HMAC computed with it.

Every webhook request includes this header:

```
X-Synup-Signature: sha256=<base64( HMAC-SHA256(secret, raw_request_body) )>
```

To verify, on your side:

1. Read the **raw request body bytes exactly as received** — do **not** re-serialize the parsed JSON (key order / whitespace differences break the HMAC).
2. Compute `HMAC-SHA256(your_secret, raw_body)`, Base64-encode it, and prefix with `sha256=`.
3. Compare against the `X-Synup-Signature` header using a **constant-time** comparison.
4. If they don't match, reject the request (return `401`) and do not process it.

**Rotating the secret.** You can **Regenerate** the secret at any time. When you do, the old secret immediately stops validating and your endpoint's verified status is reset — update the secret on your server and **re-verify** before deliveries resume. (Changing the Webhooks URL also resets verification.)

## Endpoint verification

A one-time (repeatable) challenge–response handshake, triggered by the **Verify endpoint** button, that confirms your endpoint is reachable **and** actually holds the signing secret — before Synup starts sending real events. Verification is the final gate: **deliveries stay off until it passes.**

When you click **Verify endpoint**, Synup sends a signed `POST` with body:

```json
{ "event": "endpoint.verification", "nonce": "<random hex string>", "timestamp": "<ISO-8601 UTC>" }
```

Your endpoint must recognize the special event `endpoint.verification`, compute `HMAC-SHA256(secret, nonce)` as a **lowercase hex string**, and reply **HTTP 200** with that hex string as the body (a quoted/JSON-wrapped hex string is also accepted). Synup compares your reply to its own computation; on a match the endpoint is marked **verified** and deliveries turn on.

Verification is automatically **reset to unverified** when you change the Webhooks URL or regenerate the secret, or when a verification attempt fails (including when a previously-working endpoint later stops resolving or responding). Deliveries pause until you click **Verify endpoint** again.

## Delivery mechanics — the payload envelope

Every event (except the verification challenge) is delivered with the same top-level shape:

```json
{
  "event": "connection.location_connected",
  "timestamp": "2026-07-15T14:32:10Z",
  "account_id": 11073,
  "location_id": "279381",
  "data": { }
}
```

| Field | Type | Notes |
|---|---|---|
| `event` | string | The dotted event name (e.g. `campaign.sent`). |
| `timestamp` | ISO-8601 string | When the event was emitted (UTC). |
| `account_id` | integer | The Synup account the event belongs to. |
| `location_id` | string \| null | Numeric **string** for location-scoped events; **null** for brand-scoped events (social posts, social connections, social ideas). For brand-scoped events the brand is identified by `social_profile_id` inside `data`. |
| `data` | object | Event-specific fields — documented per event under **Webhooks**. |
| `agency_account_id` | integer | **Only present** when the account is managed by a parent agency account. |
| `scope` | string | **Only present for AI Post Idea events** — `"local"` or `"social"`. |

Synup records each delivery attempt (attempt count, last attempt time, HTTP response code). Return a `2xx` to acknowledge receipt; any non-`2xx` (or a timeout) is treated as a failed attempt. Optional fields are **omitted** from `data` when empty rather than sent as `null` — always code defensively and treat missing optional keys and `null` the same way.

## Code samples

**Node.js (Express)** — capture the raw body so the HMAC matches exactly:

```js
const crypto = require('crypto');
const express = require('express');
const app = express();

const SECRET = process.env.SYNUP_WEBHOOK_SECRET;

// IMPORTANT: keep the raw body for signature verification.
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

function isValidSignature(rawBody, header) {
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('base64');
  const a = Buffer.from(header || '');
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/synup/webhooks', (req, res) => {
  if (!isValidSignature(req.rawBody, req.get('X-Synup-Signature'))) {
    return res.status(401).send('bad signature');
  }

  const body = req.body;

  // Verification handshake: reply with hex HMAC(secret, nonce).
  if (body.event === 'endpoint.verification') {
    const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex');
    return res.status(200).send(answer);
  }

  // Real event — acknowledge fast, process asynchronously.
  enqueue(body);              // your own async handoff
  return res.status(200).json({ received: true });
});
```

**Python (Flask):**

```python
import os, hmac, hashlib, base64
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SYNUP_WEBHOOK_SECRET"].encode()

def valid_signature(raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + base64.b64encode(
        hmac.new(SECRET, raw_body, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(header or "", expected)

@app.post("/synup/webhooks")
def webhooks():
    raw = request.get_data()  # raw bytes, exactly as received
    if not valid_signature(raw, request.headers.get("X-Synup-Signature")):
        abort(401)

    body = request.get_json(silent=True) or {}

    # Verification handshake.
    if body.get("event") == "endpoint.verification":
        answer = hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest()
        return answer, 200

    enqueue(body)             # your own async handoff
    return {"received": True}, 200
```

## Operational notes & FAQ

- **One URL, all events.** There is no per-event subscription. If webhooks are enabled and your endpoint is verified, you receive every event type.
- **Always verify the signature.** Treat any request that fails signature verification as hostile — return `401` and ignore it.
- **Use the raw body for the HMAC.** Re-serializing parsed JSON changes bytes and breaks verification.
- **Acknowledge fast (`2xx`), process later.** You have ~10 seconds. Enqueue and return; do slow work off the request path.
- **Expect occasional duplicates.** De-duplicate on the stable IDs inside `data`.
- **Ordering is not guaranteed.** Don't assume events arrive in the exact order they occurred.
- **Rotating the secret or changing the URL resets verification** — update your server and click **Verify endpoint** again to resume deliveries.
- **`location_id` is `null` for brand-scoped events** (social posts, social connections, social ideas). Use `social_profile_id` inside `data` to identify the brand.
- **`agency_account_id`** appears only when the account is managed by a parent agency.
