TasshubDocs
Go to App

Partner Engagement API · v1

Partner Engagement API

Poll one read-only endpoint with an API key to watch a public Tasshub post's engagement climb during a campaign. You get aggregate counts and a freshness timestamp, and nothing else.

Start here

Quickstart

Three steps get you your first response. Send your key, call the endpoint with a post and a start time, and read the counts back.

  1. 1Send your API key as a bearer token in the Authorization header. Keys begin with tsh_live_.
  2. 2Call GET /api/partner/v1/engagement with a post_url and a since timestamp.
  3. 3Read the aggregate counts from the JSON response.
Authorization: Bearer tsh_live_YOUR_KEY
GET /api/partner/v1/engagement?post_url=<post>&since=<iso-8601>

Authentication

Send your key as a bearer token

Every request carries your API key in the Authorization header. Keys begin with tsh_live_.

header
Authorization: Bearer tsh_live_YOUR_KEY

Your Tasshub contact issues your key and hands it to you once. It cannot be read back afterwards, by you or by Tasshub, so store it in your secret manager the moment you receive it. If it is lost, ask your contact for a replacement key.

Endpoint

One request, two parameters

The endpoint is a single GET. It takes the post you are measuring and the start of the window you care about.

endpoint
GET /api/partner/v1/engagement

post_url (required)

The public URL of the post to measure. It is canonicalized before lookup, so equivalent forms resolve to the same post. The canonical form is https://tasshub.com/post/<uuid>.

  • The URL must use https.
  • The host is lowercased.
  • Any query string and fragment are removed.
  • A trailing slash is removed.
  • The result must be a Tasshub post URL of the form shown above.

The host in post_url names the post, not the server you are calling. Always send https://tasshub.com/post/<uuid>; any other host is rejected with a 400.

since (required)

An ISO-8601 timestamp marking the start of the window. It must not be in the future. If it predates the post, it is raised to the post's creation time; the response echoes the value actually used as window.since, which is why that field is not always what you sent.

Response

The JSON you parse

A JSON object echoing the canonicalized post_url, a measured_at timestamp for when the counts were computed, and a window holding its since and the nested counts.

200 OK
{
  "post_url": "https://tasshub.com/post/<uuid>",
  "measured_at": "2026-07-08T10:32:14.000Z",
  "window": {
    "since": "2026-07-08T10:00:00.000Z",
    "counts": {
      "likes": 8,
      "comments": 2,
      "shares": { "external": 3, "internal": 1, "copied_link": 2 },
      "saves": 4
    }
  }
}

Metrics

What each number counts

Every count is a number of people, not a number of events, measured inside the window you set with since. Shares split into three buckets so you can weigh each channel on its own.

FieldCounts distinct
likesDistinct users who liked the post within the window. One per person.
commentsDistinct commenters within the window. Ten comments from one person count as one, so this number is lower than the comment count shown on the post page.
shares.externalDistinct users who shared the post off-platform, for example to Telegram or X, within the window. One person sharing to two apps counts twice; sharing to one app five times counts once.
shares.internalDistinct users who shared the post through an in-app direct message within the window.
shares.copied_linkDistinct users who copied the post link within the window.
savesDistinct users who saved the post within the window. One per person.

A worked example: two people, one afternoon

Watch what eight real actions do to the counts of a fresh post. Every count starts at zero.

  • alice likes the post. likes goes to 1
  • alice sends a comment. comments goes to 1
  • alice sends a second comment. comments stays at 1. Two comments, one commenter.
  • alice copies the link, shares to Telegram, DMs the post, saves it. copied_link, external, internal and saves each go to 1
  • alice shares to a second app. shares.external goes to 2. A new person-and-channel pair.
  • alice copies the link again. shares.copied_link stays at 1. Same person.
  • bob likes and comments. likes goes to 2 and comments goes to 2. A new person moves counts.

Polling

Freshness and the response cache

Responses are cached briefly, so you can poll steadily without hammering the database or paying for numbers that have not moved.

Responses are cached for about 20 seconds, keyed on the post and your since value. Two calls five seconds apart therefore return an identical body, down to measured_at, and a cadence of roughly once every 20 seconds is ideal. Each response carries a measured_at timestamp telling you exactly how fresh the counts are; a cached response keeps its original measured_at, so the freshness signal stays honest.

Rate limit

Stay under the per-minute cap

Each API key is allowed 1,000 requests per minute, measured in fixed one-minute windows.

When you exceed the rate limit the endpoint responds with 429 and a Retry-After header giving the whole seconds until the current window resets. Wait exactly that long before your next call. At the ~20 second polling cadence the cache rewards, 1,000 requests per minute leaves room for hundreds of posts on a single key.

Errors

Handling failures

Branch on the HTTP status before you retry. Each status tells you whether to back off, correct the request, or check your key.

The endpoint authenticates before it validates parameters. A caller without a valid key receives 401 whatever the parameters say, so a 400 only ever reaches a caller whose key is good. If you expected a complaint about your parameters and got 401, fix the key first.

  • 400 Bad Request: The post_url or since parameter is malformed: a non-https URL, a URL that is not a Tasshub post, a non-UUID id, or a since value that is not a valid ISO-8601 timestamp or is in the future. Correct the request rather than retrying it.
  • 401 Unauthorized: The API key is absent, wrong, or has been revoked.
  • 403 Forbidden: The API key is valid but does not carry the engagement read scope.
  • 404 Not Found: The post does not exist, or it is not public. The reply is deliberately identical for both, so a 404 tells you nothing about whether the post exists.
  • 429 Too Many Requests: The per-minute request limit for this key was exceeded. Read the Retry-After response header, wait exactly that many whole seconds, then poll again.

The order the checks run in

The first check that refuses is the one that answers. If two things are wrong at once, fix them in this order.

  1. 1.The API key401{"error":"unauthorized"}
  2. 2.The key scope403{"error":"insufficient scope"}
  3. 3.post_url400{"error":"invalid post_url"}
  4. 4.since400{"error":"invalid since"}
  5. 5.The rate limit429{"error":"rate limit exceeded"}
  6. 6.The post404{"error":"not found"}

Handling errors well comes down to the status code. On a 429, read the Retry-After header and back off for that many seconds before polling again. On a 400, correct the malformed post_url or since rather than retrying. On a 401 or 403, check the key and its scope. A 404 means the post is missing or not public; a removed post is reported the same as one that never existed.

Examples

Call it from your stack

URL-encode the post_url value, since it is itself a URL. The Node example shows a Retry-After back-off inline.

curl
curl -H "Authorization: Bearer tsh_live_YOUR_KEY" \
  "https://tasshub.com/api/partner/v1/engagement?post_url=https%3A%2F%2Ftasshub.com%2Fpost%2F<uuid>&since=2026-07-08T10:00:00.000Z"
Node
const params = new URLSearchParams({
  post_url: 'https://tasshub.com/post/<uuid>',
  since: '2026-07-08T10:00:00.000Z',
})

const res = await fetch(
  `https://tasshub.com/api/partner/v1/engagement?${params}`,
  { headers: { Authorization: 'Bearer tsh_live_YOUR_KEY' } },
)

if (!res.ok) {
  if (res.status === 429) {
    const wait = Number(res.headers.get('Retry-After') ?? 20)
    // Wait exactly the seconds the response asks for, then poll again.
    await new Promise((r) => setTimeout(r, wait * 1000))
  }
  throw new Error(`engagement poll failed: ${res.status}`)
}

const data = await res.json()
a client
export async function fetchEngagement(postUrl, since) {
  const params = new URLSearchParams({ post_url: postUrl, since })
  const res = await fetch(
    `${BASE}/api/partner/v1/engagement?${params}`,
    { headers: { Authorization: `Bearer ${process.env.TASSHUB_API_KEY}` } },
  )
  if (res.status === 429) {
    const wait = Number(res.headers.get('Retry-After') ?? 20)
    await new Promise((r) => setTimeout(r, wait * 1000))
    return fetchEngagement(postUrl, since)
  }
  if (!res.ok) throw new Error(`engagement poll failed: ${res.status}`)
  return res.json()
}
a poller
// a poller on your own server, at the cadence the cache rewards
const poll = setInterval(async () => {
  const data = await fetchEngagement(POST_URL, SINCE)
  const counts = data.window.counts
  if (counts.likes >= GOAL.likes && counts.shares.external >= GOAL.shares) {
    clearInterval(poll)
    bot.sendMessage(CHAT_ID, 'Campaign goal reached.')
  }
}, 20000)
Partner Engagement API v1 - Tasshub