Help · for developers

Linked records: tying SupahPost to your own software.

For people who write code. If your business already keeps a record for each show, client, sponsor or campaign, this is how SupahPost and that system point at the same thing.

This builds on the API and on events sent to your own software. Back to the help center.

What a linked record is

A link joins one thing in your workspace to one record in another system. It holds four things you give it and nothing else:

  • What it is on — object_type and object_key. The kinds are brand, post, draft, account, report.
  • system — your own short name for the other system, such as crm. Letters, digits, dashes and underscores, up to 40 characters. Pick one and use it every time.
  • external_id — that system's id for the record, up to 200 characters. It is kept exactly as you send it and never interpreted.
  • url and label — optional. Where a person opens the record (it must start with https://) and what they call it. Both are shown beside the link in the app.

What object_key is depends on the kind:

  • brand — the brand's key, from GET /api/v1/brands.
  • post — a scheduled post's id, from GET /api/v1/scheduled-posts.
  • draft — a design's id. The API does not list designs, so a design is linked from an AI you have connected rather than from here.
  • account — a connected account's id, from GET /api/v1/accounts.
  • report — a saved report's id, from GET /api/v1/reports.

One thing can carry up to 25 links, and one record can be linked to as many things as you like — a sponsor deal linked to a brand, six posts and the monthly report is seven links to one record.

Nothing is ever sent to your system, and the address is never opened from our side. We do not need to know what your software is. A link is a note the two systems agree on; your code keeps both ends in step.

The usual shape is one brand per show or client, each linked to its record, with a campaign or a deal linked to the individual posts and reports that belong to it. People in the workspace see and edit the same links on the Brand page and on a scheduled post.

Keys

Use an API key from Settings → Connections. Reading links needs the read scope; making or removing one needs write.

A key limited to one brand cannot use linked records at all — neither these four calls nor a read by record. Use a key without a brand limit for this integration.

The four calls

Link. PUT /api/v1/links. A link is identified by what it joins, so sending the same pair again does not make a second link — it refreshes the address and the label, and created comes back false.

curl -X PUT https://app.supahpost.com/api/v1/links \
  -H "Authorization: Bearer $SUPAHPOST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"object_type":"brand","object_key":"morning-show","system":"crm","external_id":"acct-0061x","url":"https://crm.example.com/accounts/0061x","label":"Morning Show"}'
{
  "data": {
    "object_type": "brand",
    "object_key": "morning-show",
    "system": "crm",
    "external_id": "acct-0061x",
    "url": "https://crm.example.com/accounts/0061x",
    "label": "Morning Show",
    "created": true
  }
}

List. GET /api/v1/links. With object_type and object_key it is the links on one thing; with object_type alone, on every thing of that kind; with neither, every link in the workspace. system narrows any of them.

curl "https://app.supahpost.com/api/v1/links?object_type=brand&object_key=morning-show" \
  -H "Authorization: Bearer $SUPAHPOST_KEY"

Look up by record. GET /api/v1/links/lookup goes the other way: give your system's name and your record's id and get everything linked to it. linked groups the keys by kind; things names each one. An id nothing is linked to answers an empty result, not an error.

curl "https://app.supahpost.com/api/v1/links/lookup?system=crm&external_id=acct-0061x" \
  -H "Authorization: Bearer $SUPAHPOST_KEY"
{
  "data": {
    "system": "crm",
    "external_id": "acct-0061x",
    "linked": {
      "brand": [
        "morning-show"
      ]
    },
    "things": [
      {
        "object_type": "brand",
        "object_key": "morning-show",
        "name": "Morning Show",
        "account_ids": [
          12,
          13
        ]
      }
    ]
  }
}

Unlink. DELETE /api/v1/links, with the same fields in the query string. With system and external_id it removes that one link; with system alone, every link the thing has to that system; with neither, every link the thing carries. The answer says how many went. Only the link is removed — the thing here and your record are untouched.

curl -X DELETE "https://app.supahpost.com/api/v1/links?object_type=brand&object_key=morning-show&system=crm&external_id=acct-0061x" \
  -H "Authorization: Bearer $SUPAHPOST_KEY"
{
  "data": {
    "removed": 1
  }
}

Creating a post that is already linked

POST /api/v1/scheduled-posts takes links: up to 10 entries of { system, external_id, url?, label? }. The post is created already tied to your records, so there is no moment when it exists without them.

The list is checked in full before anything is created. If one entry cannot be saved the whole request is refused and no post is made. Send an Idempotency-Key header and a retry returns the original post rather than a second one.

curl -X POST https://app.supahpost.com/api/v1/scheduled-posts \
  -H "Authorization: Bearer $SUPAHPOST_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: deal-4471-announcement" \
  -d '{"account_ids":[12],"caption":"Thanks to our spring sponsor.","when":"tomorrow 6pm","links":[{"system":"crm","external_id":"deal-4471","label":"Spring sponsorship"}]}'

Add "is_draft": true to make it without queuing it, or "requires_approval": true to hold it until a person in the workspace says yes.

Reading by record

Add record_system and record_id to a read and the answer covers only what you linked to that record. These take the pair:

  • GET /api/v1/posts
  • GET /api/v1/metrics
  • GET /api/v1/earnings
  • GET /api/v1/reports
  • GET /api/v1/scheduled-posts
  • GET /api/v1/scheduled-posts/results

What a record covers follows from its links:

  • A brand link covers that brand's accounts. An account link covers that account.
  • The analytics reads (/api/v1/posts, /api/v1/metrics, /api/v1/earnings) answer for those accounts.
  • The scheduled-post reads return the posts linked to the record, and every post going to one of its accounts.
  • /api/v1/reports returns the reports linked to the record, and those drawn only from its accounts.
curl "https://app.supahpost.com/api/v1/metrics?range=last%2030%20days&record_system=crm&record_id=acct-0061x" \
  -H "Authorization: Bearer $SUPAHPOST_KEY"

A record nothing is linked to never returns the whole workspace. The analytics reads answer 404; the lists answer an empty list. The same 404 is returned for a record linked only to a post or a report, because there are no account figures for it — use the scheduled-post reads for those.

Give both fields or neither, and do not combine them with brand.

To follow the posts of one deal, read /api/v1/scheduled-posts/results by record: each row is one account's delivery, with the post's address on that platform once it is out, or the reason it did not go.

Your ids on every event

7 of the events carry your ids, so a receiver never has to look ours up: post.published, post.failed, post.scheduled, post.approval_requested, post.approved, post.changes_requested, report.generated. Three fields are added to data when there is something to say:

  • links — the records linked to the post, or to the saved report, itself.
  • brand — the key of the one brand every account of that post or report belongs to.
  • brand_links — the records linked to that brand.
{
  "id": "evt_5f0c2a9e-6f0b-4f0e-9d53-0c1f6e1f2a77",
  "type": "post.published",
  "created_at": "2026-10-09T22:00:04.000Z",
  "workspace_id": 318,
  "data": {
    "post_id": 812,
    "target_id": 1440,
    "platform": "facebook",
    "account_id": "10555511122",
    "brand": "morning-show",
    "links": [
      {
        "system": "crm",
        "external_id": "deal-4471"
      }
    ],
    "brand_links": [
      {
        "system": "crm",
        "external_id": "acct-0061x"
      }
    ]
  }
}

Four things to know before you rely on them:

  • Ids only. Each entry is exactly system and external_id. The address and the label never leave.
  • An id must look like an id to travel. Letters, digits and _ . : @ | -, up to 64 characters. An id with a space or a slash in it is still stored and still readable over the API, but it is left off the event. The same rule applies to brand: a brand whose key has a space in it is not named, though its brand_links still are.
  • No guessing. If a post goes to accounts in more than one brand, or in none, brand and brand_links are absent.
  • Absent means none. A field with nothing in it is left out rather than sent as an empty list. At most 25 entries are sent per field.

The ids are read at the moment the event happens. A link added afterwards is not on an event already sent.

The whole loop, once

A show has a record in your CRM, acct-0061x, and a brand in SupahPost, morning-show. A sponsor deal, deal-4471, needs a post.

  1. Link the brand, once. PUT /api/v1/links with the first body above. From now on anything about morning-show can be asked for as acct-0061x.
  2. Push the post with its deal. POST /api/v1/scheduled-posts with links naming deal-4471. Keep the post.id if you like; you will not need it.
  3. Hear that it went out. Your endpoint receives post.published with links naming deal-4471 and brand_links naming acct-0061x — the body shown above. Mark the deal delivered from that alone.
  4. Read how it did. GET /api/v1/scheduled-posts/results?record_system=crm&record_id=deal-4471 for the post's own link on each platform, and GET /api/v1/metrics?record_system=crm&record_id=acct-0061x for the show as a whole.
  5. When the deal ends, leave the link in place — it is what makes last year's posts findable by deal. Remove a link only when it was wrong.

If your CRM is the system of record, run step 1 from it whenever a show is created, and call GET /api/v1/links?object_type=brand on a schedule to check that every brand has its record.

When a call is refused

Errors use the API's usual shape, { "error": { "code", "message" } }, and the message is a sentence you can show a person.

  • 400 — a field is missing, misspelt or not one this endpoint takes. An unknown field is refused, never ignored.
  • 403 — the key lacks the scope, or is limited to one brand.
  • 404 — the thing named is not in this workspace, or nothing with accounts is linked to the record you asked to read by.
  • 422 — the request was understood and cannot be saved: an address that is not https, a system name with other characters in it, or a 26th link on one thing.