The API

Read and manage your phrases and matches from your own code, and get webhooks, with an API key.

The API

The API lets your own code use your ListeningKit data: read your plan, phrases and matches, add and change phrases, and (on Pro) get a webhook when a strong match appears.

Make a key in the dashboard, under API. Each key belongs to you, sees only your own data, and can do only what you allowed when you made it.

Getting a key

  1. Open API in the dashboard.
  2. Type a name (for example "My reporting script") and choose the scopes the key needs (below).
  3. Press Create key and copy the key now. It is shown only once. ListeningKit keeps only a fingerprint of it, so a lost key cannot be recovered: make a new one.
  4. Revoke a key any time with Revoke. It stops working immediately.

You can have up to 5 keys.

Scopes

A scope is a permission. Choose the least a key needs.

ScopeLets the key
readRead your plan, phrases and matches. Every key has it.
write:phrasesAdd, pause, resume and remove phrases.
webhooksManage webhook subscriptions (a Pro feature).

A key with write:phrases or webhooks can change your data. Keep it out of code you publish and out of browsers, and revoke it if it leaks. Keys made before scopes existed can only read.

A request that needs a scope the key does not have gets 403 with "code": "forbidden" and the missing scope named.

Calling it

The base address is shown on the API page. It looks like https://<your deployment>.convex.site/api/v1. Send the key in the Authorization header:

curl "https://<your deployment>.convex.site/api/v1/matches?limit=5&min_score=70" \
  -H "Authorization: Bearer lk_api_..."

Every response is JSON.

Reading (scope: read)

GET /me

Your plan, its limits and what you use.

{ "data": { "plan": "free", "name": "Free",
    "limits": { "phrasesPerPlatform": 1, "accountsPerPlatform": 1, "webhooksPerPerson": 0 },
    "usage": { "phrases": { "reddit": 0, "x": 1, "facebook": 0 }, "accounts": { "reddit": 0, "x": 1, "facebook": 0 }, "webhooks": 0 } } }

GET /keywords and GET /keywords/{id}

Your phrases, or one of them.

{ "data": [ { "id": "…", "phrase": "need a helper", "platform": "x", "community": null,
    "status": "listening", "matches": 34, "lastCheckedAt": 1790000000000, "lastSource": "helper" } ] }

community is the subreddit for Reddit phrases and null otherwise. lastSource is reddit, mirror or helper.

GET /matches and GET /matches/{id}

Your matches, newest first, or one of them.

ParameterMeaning
limitHow many to return, 1 to 100. Default 25.
platformOnly reddit, x or facebook.
min_scoreOnly matches scored at least this, 0 to 100. Unscored matches are left out when you use it.
beforeThe nextCursor of the previous page, to get the next page.
{ "data": [ { "id": "…", "createdAt": 1790000000000.5, "phrase": "need a helper", "platform": "x",
      "score": 88, "intent": "looking_for_help", "reason": "Asks for a helper this week",
      "post": { "author": "someone", "title": null, "text": "…", "url": "https://x.com/…",
                "likes": 3, "comments": 1, "postedAt": null } } ],
  "nextCursor": 1789999999000.25 }

score, intent and reason are null until a match has been scored. text is cut at 2000 characters. nextCursor is null when there is nothing older. To read everything, keep calling with before set to the last nextCursor until it is null.

Writing phrases (scope: write:phrases)

These follow exactly the same rules as the dashboard, including the plan limit (the Free plan has 1 phrase per platform). A phrase you add here is a phrase in the dashboard, and the other way round.

POST /keywords

curl -X POST "$BASE/keywords" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: order-1042" \
  -d '{"phrase": "need a bookkeeper", "platform": "reddit", "community": "smallbusiness"}'

Answers 201 with the phrase. community is required for Reddit and not allowed for the others.

Retrying safely: send an Idempotency-Key header (1 to 64 letters, numbers or _ . : -). If you send the same key again within a day, you get the phrase that was already made (status 200, header Idempotent-Replayed: true) instead of a duplicate.

PATCH /keywords/{id}

Pause or resume: {"status": "paused"} or {"status": "listening"}.

DELETE /keywords/{id}

Removes the phrase and its matches. Answers {"data": {"id": "…", "deleted": true}}.

Webhooks (scope: webhooks, a Pro feature)

A webhook sends a signed message to your server the moment a match is scored at or above the level you choose, so your code can react without polling.

Webhooks are part of the Pro plan, which is coming soon. On the Free plan the endpoints answer 403 with "code": "plan_limit". The site's operator can switch them on for a deployment.

EndpointDoes
POST /webhooksAdd one: {"url": "https://…", "min_score": 70, "platforms": ["x"]}. min_score defaults to 70 and platforms to all. The signing secret is in this response only.
GET /webhooksList them.
PATCH /webhooks/{id}{"active": true} or {"active": false}, or change min_score and platforms.
DELETE /webhooks/{id}Remove it and its delivery log.
POST /webhooks/{id}/testSend one signed ping event now and see what your server answered.
GET /webhooks/{id}/deliveriesThe last 20 deliveries: status, tries, your server's HTTP status, and a plain reason if it failed.

What we send

A POST with a JSON body:

{ "id": "<delivery id>", "type": "match.created", "createdAt": "2026-09-21T12:00:00.000Z",
  "data": { "id": "…", "score": 88, "phrase": "need a helper", "platform": "x", "post": { "…": "…" } } }

data is the same match GET /matches/{id} returns. The ping event sent by the test has type: "ping".

Headers:

HeaderValue
X-ListeningKit-Eventmatch.created or ping
X-ListeningKit-DeliveryThe delivery id (the same as id in the body)
X-ListeningKit-TimestampSeconds since 1970 when we sent it
X-ListeningKit-Signaturev1= plus the hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your signing secret

Answer with any 2xx status, quickly (we wait 5 seconds). The body of your answer is ignored and never stored.

Check the signature

Always check it, and reject requests with an old timestamp (more than 5 minutes) so a captured request cannot be replayed.

// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'

export function isFromListeningKit(rawBody, headers, secret) {
  const timestamp = headers['x-listeningkit-timestamp']
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
  const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
  const given = headers['x-listeningkit-signature'] ?? ''
  return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given))
}
# Python
import hashlib, hmac, time

def is_from_listeningkit(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers["x-listeningkit-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, headers.get("x-listeningkit-signature", ""))

Use the raw body exactly as received, not a re-serialized copy.

Retries and switching off

If your server does not answer 2xx, we try again after 1 minute, 5 minutes, then 30 minutes, four tries in all, then mark the delivery failed. After 5 failed deliveries in a row the webhook switches itself off (with the reason on it) so it stops sending to a broken address. Fix your server, then turn it back on with PATCH and {"active": true} or in the dashboard. A failed test does not count against it.

What we refuse

The address must be https, a real public website name, on the normal port, with no username or password. We refuse IP addresses, localhost, names ending in .local, .internal, .lan and similar, and names that resolve to private addresses through services like nip.io. Deliveries never follow redirects. Some of this is checked again every time we send.

Limits and errors

60 requests per minute per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. Past the limit you get 429 and a Retry-After header with the seconds to wait.

Errors look the same everywhere:

{ "error": { "code": "invalid_parameter", "message": "limit must be a whole number from 1 to 100" } }
StatuscodeMeaning
400invalid_parameter, invalid_request, invalid_jsonSomething in the request is wrong. The message says what.
401unauthorizedNo key, a malformed key, an unknown key, or a revoked key.
403forbiddenThe key lacks the scope the request needs.
403plan_limitYour plan does not allow it (for example a second phrase on the Free plan, or webhooks on Free).
404not_foundNo such phrase, match or webhook, or it is not yours.
409conflictYou already have that phrase or webhook.
413payload_too_largeThe body is over 4 KB.
429rate_limitedToo many requests. Wait for Retry-After seconds.
500server_errorSomething went wrong on our side. Try again.

Not the same as an ingest key

Ingest keys (Settings, Send posts in) let the X and Facebook helpers send posts in, and start with lk_ingest_. API keys work with your data, and start with lk_api_. Neither works in the other's place.