# The Indably API and webhooks

> Create, schedule and track social posts in Indably from your own systems, with idempotent writes and signed webhooks.

Published 20 October 2026 by Indably (https://indably.com/guides/indably-api-and-webhooks).

For any system that posts through Indably. The machine-readable reference is at **`https://app.indably.com/openapi/v1.json`** (OpenAPI 3); this page is the guide.

## Keys

An Owner makes a key under **Settings → API and webhooks**. A key:

- belongs to the account and acts **as the Owner who made it**, in the brands (workspaces) that person belongs to;
- is capped at **Manager** (write, approve, schedule) or **Contributor** (write and send for review), whichever was chosen; the approval workflow applies exactly as in the app;
- is shown once, stored only as a SHA-256 fingerprint, and stops working when revoked or when its maker leaves the account.

Send it on every request:

```
Authorization: Bearer ind_k7Qm2x…
```

Without a valid key the API answers `401` with a problem-details body. It never accepts a browser's sign-in cookie. Limit: 120 requests a minute per key (`429` above that).

## Conventions

- JSON in and out, camelCase. Times are **UTC**, ISO 8601 (`2026-10-20T07:00:00Z`).
- Ids are GUIDs. Enum values are snake_case: `draft`, `in_review`, `approved`, `scheduled`, `published`, `failed`; targets also `pending`, `publishing`, `needs_check`, `ready_to_post`.
- Errors are [RFC 9457 problem details](https://www.rfc-editor.org/rfc/rfc9457): `{"status": 409, "title": "…"}`. `title` is written for a person and safe to show.
  - `400` bad input, `401` no or bad key, `403` the key's role can't do that here, `404` not visible to this key, `409` the post isn't in a state that allows it (the title says why), `422` Idempotency-Key reused for a different request, `429` slow down.

## Idempotency: never post twice

Every write accepts an **`Idempotency-Key`** header (any string up to 100 characters, unique per intended action, e.g. your order id plus "-post"). If Indably's answer is lost and you send the same request with the same key, you get **the first answer back** (with `Idempotent-Replayed: true`) instead of a second post. The same key with a different body is refused (`422`); a retry while the first is still running gets `409`, so wait a second and try again. Keys are remembered for seven days.

Always send one on `POST /posts`. It is the API's version of Indably's own rule: never retry an unknown outcome blind.

## Endpoints

| Method and path | What it does |
|---|---|
| `GET /api/v1/me` | The key, its account, its role cap, and the brands it can reach |
| `GET /api/v1/workspaces` | The brands (id, name, time zone, whether approval is required, the key's role there) |
| `GET /api/v1/workspaces/{id}/connections` | The accounts a brand can publish to; use their ids in a post's `connections` |
| `POST /api/v1/workspaces/{id}/media` | Upload a photo or video (multipart field `file`, optional `altText`). Photos are re-encoded to JPEG with location data removed |
| `POST /api/v1/workspaces/{id}/posts` | Create a post (below) |
| `GET /api/v1/workspaces/{id}/posts?status=&from=&to=&limit=` | Posts, newest first; `from`/`to` filter on the scheduled time |
| `GET /api/v1/workspaces/{id}/posts/{postId}` | One post with each target's status, link, error and latest numbers |
| `POST …/posts/{postId}/submit` | Send a draft for review (or approve it, if the key may) |
| `POST …/posts/{postId}/approve` | Approve a post in review (Manager keys) |
| `POST …/posts/{postId}/send-back` | `{"note": "…"}` Return it to the author with a note (Manager keys) |
| `POST …/posts/{postId}/schedule` | `{"at": "…"}` or `{"queue": true}` for the brand's next free posting time |
| `POST …/posts/{postId}/unschedule` | Back to approved, if publishing hasn't started |
| `DELETE …/posts/{postId}` | Delete a draft or unscheduled post (published posts stay on record) |
| `GET /api/v1/workspaces/{id}/report?from=2026-10-01&to=2026-10-31` | Totals and every published post's numbers for dates in the brand's time zone |

### Create a post

```http
POST /api/v1/workspaces/0199f4a2-…/posts
Authorization: Bearer ind_…
Idempotency-Key: heypaid-order-1042-post
Content-Type: application/json

{
  "caption": "Payday special: 20% off cupping sessions this Saturday.",
  "link": "https://harbourroast.co.za/cupping",
  "firstComment": "#durban #coffee",
  "connections": ["0199f4a3-…", "0199f4a3-…"],
  "media": ["0199f4b0-…"],
  "captions": { "0199f4a3-…": "A shorter version for X." },
  "scheduleAt": "2026-10-24T07:00:00Z"
}
```

- `connections` is required. `media`, `captions` (per connection), `options` (per connection: TikTok's `privacy` and disclosure switches, YouTube's `title` and `privacy`, Google's `button`; see the OpenAPI schema), `utmCampaign` are optional.
- **What happens next** depends on one of: `scheduleAt` (schedule at that time), `queue: true` (the next free posting time), `submit: true` (send for review). With none, it stays a **draft**.
- Scheduling needs a Manager key, or a brand with approval switched off; otherwise `403`, and you should use `submit: true`.
- Indably checks the post against each network's rules (lengths, media types, TikTok's questions) before it schedules. A problem comes back as `409` with the reason.

Answer: `201` with the post. Poll `GET …/posts/{postId}` or, better, listen to webhooks.

## Webhooks

An Owner adds an https address under **Settings → API and webhooks**, with the events it wants. Indably then `POST`s JSON to it:

```json
{
  "id": "0199f5c1-…",
  "type": "post.target.published",
  "createdAt": "2026-10-24T07:00:03Z",
  "workspace": { "id": "0199f4a2-…", "name": "Harbour Roast" },
  "data": { "post": { …the post as the API returns it… }, "target": { …the one network it's about… } }
}
```

| Event | When |
|---|---|
| `post.submitted` | Sent for review |
| `post.approved` | Approved in Indably, on WhatsApp, or by a Manager's own save |
| `post.sent_back` | Returned to the author with a note |
| `post.scheduled` | Given a time |
| `post.target.published` | Live on one network (also when Indably confirmed a post it wasn't sure about, or a person marked an assisted post as posted) |
| `post.target.failed` | The network refused it; `target.error` says why |
| `post.target.needs_check` | The network didn't answer after the post was sent; Indably will look, and a person may need to |
| `post.target.ready_to_post` | An assisted post is ready for a person to post by hand |
| `ping` | "Send a test" in settings |

**Headers:** `Indably-Event`, `Indably-Delivery` (the event id), `Indably-Signature: t=1760860800,v1=<hex>`.

**Verify** every delivery: compute HMAC-SHA256 of `t + "." + raw body` with your signing secret and compare with `v1` in constant time; reject anything older than five minutes.

```csharp
static bool Verify(string secret, string header, string body)
{
    var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
    if (!long.TryParse(parts["t"], out var t) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
    using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexStringLower(h.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{body}")));
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(parts["v1"]));
}
```

**Delivery** is at least once. Answer `2xx` within 10 seconds; anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then given up. A slow answer can mean the same event arrives twice: **ignore an `Indably-Delivery` id you've already handled.** After 20 failures in a row the webhook is switched off and the account's Owners are told. Indably only calls public https addresses: never private networks, never redirects.

## What isn't in version 1

Ads, analytics beyond the report, connecting accounts (people do that in the app, because the networks' sign-ins need a person), and member management.
