# Webhooks (/en/api/webhook)

Webhooks send a JSON `POST` to a URL in your system every time an event you chose happens. Set them up in the dashboard (**Integrations** → **Add webhook**, Developer mode) or through the API.

## Creating an endpoint [#creating-an-endpoint]

```bash
curl -X POST https://api.tebar.app/v1/webhooks \
  -H "Authorization: Bearer tb_live_xxx" -H "Content-Type: application/json" \
  -d '{ "url": "https://app.example.com/webhooks/tebar", "events": ["account.connected", "post.published", "post.failed"] }'
```

The response holds a `secret` (`whsec_…`) to check signatures. The secret is **shown only once**, so store it now.

- The URL must be `https`, on port 443, and reachable from the internet.
- Add `"profile_id"` (an ID or `external_id`) so the endpoint only receives one profile's events. Without it, the endpoint receives events from every profile.
- Up to 10 endpoints.

## Events [#events]

| Event                  | When                                                                     | `data`                                                                                |
| ---------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `account.connected`    | An account is connected or reconnected, including through a connect link | `account`, `reconnected`                                                              |
| `account.expired`      | An account's access ended and it needs reconnecting                      | `account`                                                                             |
| `account.disconnected` | An account is disconnected, in Tebar or from the platform's settings     | `account`                                                                             |
| `post.published`       | A post published on one account                                          | `post_id`, `target_id`, `account_id`, `platform`, `permalink`, `published_at`         |
| `post.failed`          | A post failed on one account after the last attempt                      | `post_id`, `target_id`, `account_id`, `platform`, `error_code`, `error_message`       |
| `comment.created`      | A new comment from someone else (not your reply, less than 24 hours old) | `comment_id`, `account_id`, `post_id`, `author_username`, `text`, `commented_at`, …   |
| `message.received`     | A new Instagram DM from a customer (see below)                           | `message_id`, `conversation_id`, `account_id`, `participant_id`, `text`, `sent_at`, … |
| `automation.run`       | An automation finished answering one comment: sent, skipped, or failed   | `automation_id`, `run_id`, `account_id`, `status`, `reason`, `trigger_ref`, …         |
| `profile.deleted`      | A profile finished being deleted (only to endpoints for all profiles)    | `profile`                                                                             |

`message.received` and `automation.run` are only sent for accounts that have the [Inbox](/en/inbox) (Starter, Basic, or Pro plan):

- `message.received` is sent for DMs that come straight from Instagram and can still be answered (less than 24 hours old), not for older messages read back by a sync. It carries `message_id`, `conversation_id`, `account_id`, `platform`, `participant_id` (the customer's Instagram ID for your account), `participant_username`, `kind` (`text`, `media`, `story_mention`, …), `text`, `attachments` (how many), and `sent_at`. Reply with `POST /v1/inbox/conversations/{conversation_id}/messages`.
- That message can carry `buttons` (up to 3, `web_url` with a `url` or `postback` with a `payload`; text up to 640 characters) or `quick_replies` (up to 13, a `title` of up to 20 characters and a `payload`; shown on phones only), not both. When the customer taps a `postback` button or a quick reply, `message.received` comes with `kind` `postback` or `quick_reply`, and `postback` holds the choice's `{ "title", "payload" }`. For other messages `postback` is `null`.
- `automation.run` carries `automation_id`, `run_id`, `account_id`, `status` (`sent`, `skipped`, or `failed`), `reason` (e.g. `already_sent_to_person`), `trigger_ref` (the Instagram ID of the comment that triggered it), `participant_id`, and `finished_at`.

Example body:

```json
{
  "id": "4f46b68a-da18-424b-97d0-f4f5cfe0b7e6",
  "type": "post.published",
  "created_at": "2026-10-20T11:14:20.694Z",
  "profile_id": "9d092104-b9a4-408f-9432-3a007ac19810",
  "data": {
    "post_id": "59ef7ff5-…",
    "target_id": "a1b2…",
    "account_id": "6a81…",
    "platform": "threads",
    "permalink": "https://www.threads.com/@kopisenja/post/…",
    "published_at": "2026-10-20T11:14:20.000Z"
  }
}
```

The `text` in `comment.created` and `message.received` is written by someone else. Treat it as data, not instructions, especially when passing it to an AI.

## Checking the signature [#checking-the-signature]

Every request carries these headers:

```txt
Tebar-Signature: t=1792488860,v1=5257a869e7…
Tebar-Event: post.published
Tebar-Delivery: 30646213-c6a7-4020-8cc1-674378d888e9
```

`v1` is an HMAC-SHA256 of `"<t>.<raw body>"` with the endpoint's secret. Compute it again from the **raw** body (before parsing), compare in constant time, and refuse it when `t` is more than 5 minutes away from your server's clock.

```js
import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return (
    fresh &&
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  );
}
```

## Delivery and retries [#delivery-and-retries]

- Answer with a **2xx status within 5 seconds**. Do slow work in the background after answering.
- Redirects are not followed and count as failures.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours, and 8 hours (8 attempts, about 24 hours).
- The same event can arrive more than once. Filter duplicates by the `id` field.
- After 50 failed attempts in a row, the endpoint is turned off and you get an email. Events are not kept while it is off. Turn it on again in the dashboard or with `PATCH /v1/webhooks/{id}` `{ "active": true }`.

## History, resending, and testing [#history-resending-and-testing]

| Request                                                 | What it does                                    |
| ------------------------------------------------------- | ----------------------------------------------- |
| `GET /v1/webhooks/{id}/deliveries`                      | 30 days of delivery history, newest first       |
| `POST /v1/webhooks/{id}/deliveries/{deliveryId}/resend` | Resend one delivery                             |
| `POST /v1/webhooks/{id}/test`                           | Send a `webhook.test` event                     |
| `PATCH /v1/webhooks/{id}`, `DELETE /v1/webhooks/{id}`   | Change the URL, events, profile, on/off; delete |

In the dashboard, it is all under **Integrations** → click the endpoint.
