Skip to content

Tern Bridge captures WhatsApp, Messenger, Instagram and LinkedIn.

How it works
Tern
Developers

Tern Lead Feed v1

One standard way for any platform to hand its leads to Tern. Publish two read only endpoints, give the customer a URL and a token and their leads arrive in Tern on a schedule, filed under the feed name they choose.

Three steps

How to connect

  1. Step 1

    Build the endpoint to the spec

    Serve GET /health and GET /leads over HTTPS as this page describes. Check your work against the JSON schema and the reference provider.

  2. Step 2

    Share the URL and a token

    Give the Tern customer your base URL and a bearer token scoped to their data. A signing secret is optional and recommended.

  3. Step 3

    The admin adds it in Tern

    Under Integrations > Lead feeds the admin pastes both, tests the connection and names the feed. That name becomes the source of every lead.

A standard way for any platform to hand its leads to Tern. You publish two read only HTTPS endpoints; Tern polls them on a schedule, validates every record and files the leads in your customer's Tern workspace. This page is everything an engineer needs to build and test those endpoints.

Version: 1. Machine readable schema: schema.json.

1. Purpose

A Tern customer (the tenant) often has leads living in another system: a booking portal, a CRM, a form builder or an in house app. Rather than a custom integration per platform, Tern defines one pull contract:

  1. The platform (the provider) serves GET {base_url}/health and GET {base_url}/leads as described below.
  2. The provider issues the tenant a token.
  3. The tenant pastes the base URL and token into Tern, clicks Test connection and saves the feed under a name such as "Portal enquiries".
  4. From then on Tern pulls changed leads on the feed's schedule. The feed name becomes the source of every lead it brings in, so the tenant's team sees where each lead came from.

Tern only reads. It never writes back to the provider through this contract.

2. Transport

  • HTTPS only, TLS 1.2 or newer, with a certificate valid for the host name. Plain HTTP is refused.
  • The base URL must use the default port 443, a public host name (or a public IP address) and no credentials, query string or fragment. Private, loopback, link local and cloud metadata addresses are refused, as are host names such as localhost or *.internal.
  • The base URL may carry a path. With https://api.example.com/tern/v1 Tern calls https://api.example.com/tern/v1/health and https://api.example.com/tern/v1/leads.
  • Tern does not follow redirects. Any 3xx answer is treated as an error, so give Tern the final URL.
  • Every request is a GET with Accept: application/json and User-Agent: Tern-LeadFeed/1. Responses must be JSON in UTF-8 with Content-Type: application/json.
  • Tern waits at most 20 seconds for each response and reads at most 10 MB of body. Keep pages small enough to stay well inside both.

3. Authentication

3.1 Bearer token (required)

The provider issues a static token to the tenant. Tern sends it on every request:

Text
Authorization: Bearer <token>

Tokens should be long (at least 32 random characters), scoped to one tenant and revocable. Tern stores the token encrypted at rest (AES-GCM) and never shows it again after it is saved.

Rotation: issue a second token while the first still works, let the tenant paste the new token into Tern, then revoke the old one. Accept both during the changeover.

Answer an unknown or revoked token with 401 and an error body (section 9).

If you give the tenant a signing secret as well, Tern signs every request so you can prove it came from Tern and was not replayed. Tern adds two headers:

Text
X-Tern-Timestamp: <unix seconds>
X-Tern-Signature: <hex HMAC SHA256>

The signature is the lower case hex HMAC SHA256, keyed with the signing secret, of this exact string:

Text
<timestamp>.<method>.<path_and_query>
  • timestamp is the value of X-Tern-Timestamp.
  • method is the upper case HTTP method, always GET.
  • path_and_query is the request target exactly as it arrived: the full path including any base path, then ? and the query string as sent, for example /tern/v1/leads?updated_since=2026-09-18T09%3A28%3A00.000Z&limit=100. Verify against the raw request line; do not rebuild or re-encode the query.

To verify: recompute the HMAC, compare in constant time and reject timestamps more than 300 seconds from your clock. The secret must be at least 16 characters; 32 or more random bytes is better.

3.3 IP allow listing

Not required. Tern calls from Cloudflare's network, whose addresses are shared and change, so allow listing is fragile. Rely on the token and the signature instead.

4. Endpoints

4.1 GET {base_url}/health

Used by the Test connection button. Check the token (and signature, if you use one) exactly as for /leads, then answer:

JSON
{ "ok": true, "version": "1" }
FieldTypeNotes
okbooleantrue when the feed can serve leads.
versionstringAlways "1" for this version.

4.2 GET {base_url}/leads

Query parameters:

ParameterTypeNotes
updated_sinceISO 8601 date and timeInclusive: return leads whose updated_at is greater than or equal to it. Absent on the first ever run, meaning "everything".
cursorstringOpaque value from the previous page's next_cursor. Absent on the first page.
limitinteger, 1 to 500Page size. Default 100 when absent. Answer 400 with code invalid_limit outside the range.

When cursor is present Tern also repeats the same updated_since, so the cursor only needs to mark a position within that filtered set.

Response body (the page):

JSON
{
  "data": [ /* Lead objects, section 5 */ ],
  "next_cursor": "eyJ1IjoiMjAyNi0wOS0xOFQwOToyOTo1OFoiLCJpIjoibGVhZF8xMDMifQ",
  "feed": { "name": "Portal enquiries", "version": "1" }
}
FieldTypeNotes
dataarray of LeadAt most limit items, ordered as section 6 describes. May be empty.
next_cursorstring or nullOpaque cursor for the next page, at most 2048 characters. null on the last page.
feedobjectfeed.version is required and is "1". feed.name is optional; Tern may suggest it as the feed name.

5. The Lead object

Unknown top level fields are ignored, so you can add fields without breaking Tern; put anything you want kept on the lead in attributes. Any optional field may be omitted or null.

5.1 Required

FieldTypeNotes
idstring, 1 to 255 charsStable id of the lead in your system. Never reuse an id for a different person.
updated_atISO 8601 with offsetWhen anything in this Lead last changed, for example 2026-09-18T09:30:00Z or 2026-09-18T19:30:00+10:00. A value without an offset is invalid.

And at least one of email, phone or whatsapp must be present and readable (a well formed address or number), unless deleted is true.

5.2 Contact

FieldTypeNotes
emailstringUp to 320 characters. Compared case insensitively.
phonestringUp to 64 characters. See section 5.5.
whatsappstringWhatsApp number when it differs from phone. Same rules.

5.3 Optional

FieldTypeNotes
namestringFull name. Used as is; split into first and last when those are absent.
first_namestringUp to 200 characters.
last_namestringUp to 200 characters.
companystringUp to 200 characters.
countrystringISO 3166-1 alpha-2 code such as AU. Also the region for national phone numbers.
timezonestringIANA zone such as Australia/Sydney. An unrecognised zone is ignored; the rest of the lead is kept.
stagestringFree text stage or status in your system, up to 100 characters. Kept as the provider stage; Tern derives its own.
owner_emailstring (email)The person who owns the lead in your system. Tern assigns the lead to the Tern user with the same email, if there is one.
notesstringUp to 10,000 characters.
tagsarray of stringsUp to 50 tags of 1 to 64 characters. Added to the lead's labels.
source_detailstringWhere the lead came from in your system, for example a campaign or form name.
created_atISO 8601 with offsetWhen the lead was created in your system.
consentobjectSee section 5.4.
attributesobjectFlat object of extra fields: keys of 1 to 64 characters, values that are strings (up to 2000 characters), numbers, booleans or null. At most 100 keys. Kept on the lead. No nested objects or arrays.
deletedbooleantrue marks the lead as removed in your system. See section 7.
FieldTypeNotes
marketingbooleanThe lead agreed to marketing messages.
whatsappbooleanThe lead agreed to be contacted on WhatsApp.
recorded_atISO 8601 with offsetWhen that consent was captured.

Tern records these on the lead. Leave a value out when you do not know it; do not send false to mean "unknown".

5.5 Phone numbers

  • E.164 is preferred: +61412345678.
  • National numbers are accepted: 0412 345 678 is read with the lead's country, or with the feed's default region (set by the tenant in Tern, AU unless changed) when country is absent.
  • Spaces, dashes, brackets and an ext. suffix are tolerated. A number that cannot be read is dropped; if nothing readable is left the lead is invalid.

5.6 What Tern does with each field

FieldIn Tern
email, phone, whatsappIdentifiers. Tern matches them against existing leads to avoid duplicates and opens a merge review when they point at more than one lead.
name, first_name, last_nameLead name.
tagsLead labels (added, never removed by a sync).
owner_emailOwner, when a Tern user in the workspace has that email.
stageKept as the provider stage attribute.
consentconsent_marketing, consent_whatsapp and consent_recorded_at attributes.
company, country, notes, source_detail, created_at, attributesLead attributes.
timezoneLead time zone, used for quiet hours and scheduling.
Feed name (chosen in Tern)The lead's source, with the category the tenant picked for the feed.

6. Ordering, paging and resume

  • Order data by updated_at ascending, then by id ascending (plain string comparison). The order must be stable across pages.
  • updated_at must change whenever any field of the Lead changes, including deleted.
  • Cursors are opaque to Tern. A keyset cursor that encodes the updated_at and id of the last lead on the page works well and survives inserts; the reference implementation (section 12) does exactly that. Offsets are fragile when leads change during paging.
  • A cursor must stay valid for at least 24 hours. If Tern sends one you no longer recognise, answer 400 with code invalid_cursor; Tern then restarts from updated_since.
  • Tern follows next_cursor until it is null, one request at a time.
  • After a run Tern stores the greatest updated_at it read (its high water mark). The next run asks for updated_since = high water mark minus two minutes. That overlap window catches leads written with a slightly late timestamp.
  • Re-sending is harmless. Tern keys every lead by id and a hash of its content, so a lead that comes back unchanged costs nothing and a lead that changed is updated in place.
  • A run reads at most 200 pages. If more remain, Tern stores next_cursor and resumes from it on the next run.
  • A record that fails validation is skipped and reported to the tenant with its id and a reason; the rest of the page is processed normally. To fix it, correct the record and bump its updated_at.

7. Deletions

Send the lead again with deleted: true and a new updated_at. id and updated_at are enough; contact fields may be omitted. Tern then stops syncing that record, marks its source record as removed and never creates a new lead from it. A lead that already exists in Tern is kept, because the tenant's team may have conversations and history on it; the tenant can erase it in Tern. Simply dropping a lead from the feed is not a deletion, since Tern only asks for changes.

8. Rate limits and retries

  • Tern sends at most one request per second per feed and never runs two requests for the same feed at once.
  • The tenant picks the schedule (every 15 minutes by default).
  • 429 Too Many Requests: Tern honours Retry-After (seconds or an HTTP date) up to 60 seconds. A longer wait ends the run; the next scheduled run tries again.
  • 5xx, timeouts and network errors: Tern retries after 1, 2 then 4 seconds (four attempts in total), then fails the run and tries again on the next schedule.
  • Other 4xx answers are not retried. 401 and 403 are shown to the tenant as a rejected token.

9. Errors

Every non 2xx response carries:

JSON
{ "error": { "code": "invalid_cursor", "message": "cursor is not recognised" } }
FieldTypeNotes
errorobjectHolds code and message.
error.codestringShort machine readable code, up to 100 characters.
error.messagestringHuman readable explanation, up to 2000 characters. Tern shows it to the tenant, so never include secrets.

Suggested codes: unauthorized (401), forbidden (403), not_found (404), invalid_limit, invalid_updated_since and invalid_cursor (400), rate_limited (429), internal (500), unavailable (503).

10. Test connection

When the tenant clicks Test connection, Tern calls GET /health followed by GET /leads?limit=5 without updated_since. It shows the tenant the first five leads as Tern would file them plus any that fail validation. Nothing is saved by a test.

11. Data protection

  • Only send leads the tenant owns or is entitled to process. The token you issue should reach that tenant's data and nothing else.
  • Tern stores the leads in the tenant's own workspace, isolated from every other workspace. They are used only for that tenant.
  • Tern stores your token and signing secret encrypted and makes every outbound call from its server side worker, never from a browser.
  • Flag removals with deleted: true (section 7) so Tern stops syncing them.
  • Do not put secrets, passwords or payment card data in any field, including notes and attributes.

12. Reference implementation

reference-provider.ts is a complete provider in about 150 lines of Node (node:http, no dependencies). Tern's own tests sync against it. It shows:

  • the bearer token check and the optional X-Tern-Signature check (verifyTernSignature);
  • filtering on updated_since, ordering by updated_at then id;
  • a keyset cursor (base64url of the last updated_at and id);
  • limit validation and error bodies.

The signature check in Node, for reference:

TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyTern(req: { method: string; url: string; headers: Record<string, string | undefined> }, secret: string): boolean {
  const ts = req.headers["x-tern-timestamp"];
  const sig = req.headers["x-tern-signature"];
  if (!ts || !sig || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  // req.url is the raw request target: path plus query exactly as sent.
  const expected = createHmac("sha256", secret).update(`${ts}.${req.method}.${req.url}`).digest("hex");
  return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

13. Full example

Request (a feed with the signing secret example_signing_secret_0123456789, a later run, second page). The signature is the HMAC of 1789723800.GET./tern/v1/leads?updated_since=2026-09-18T09%3A28%3A00.000Z&cursor=eyJ1IjoiMjAyNi0wOS0xOFQwOToyOTo1OFoiLCJpIjoibGVhZF8xMDMifQ&limit=100, so you can check your own implementation against it:

HTTP
GET /tern/v1/leads?updated_since=2026-09-18T09%3A28%3A00.000Z&cursor=eyJ1IjoiMjAyNi0wOS0xOFQwOToyOTo1OFoiLCJpIjoibGVhZF8xMDMifQ&limit=100 HTTP/1.1
Host: api.example.com
Authorization: Bearer 3f9c1e7a52b84d0e9a6c4b1d7e2f8a90c5d3b6e1f4a7c2d9
Accept: application/json
User-Agent: Tern-LeadFeed/1
X-Tern-Timestamp: 1789723800
X-Tern-Signature: 8127883fb956f19858e6a32c41da6a35c7f81deef000d8815b6faa84f910d6db

Response:

HTTP
HTTP/1.1 200 OK
Content-Type: application/json
JSON
{
  "data": [
    {
      "id": "lead_104",
      "updated_at": "2026-09-18T09:30:12Z",
      "first_name": "Priya",
      "last_name": "Raman",
      "email": "priya.raman@example.com",
      "phone": "0412 345 678",
      "whatsapp": "+61412345678",
      "company": "Raman Consulting",
      "country": "AU",
      "timezone": "Australia/Sydney",
      "stage": "Qualified",
      "owner_email": "sam@agency.example",
      "notes": "Asked about partner visa timelines.",
      "tags": ["partner-visa", "hot"],
      "source_detail": "Spring webinar",
      "created_at": "2026-09-17T22:04:00+10:00",
      "consent": { "marketing": true, "whatsapp": true, "recorded_at": "2026-09-17T22:04:00+10:00" },
      "attributes": { "visa_subclass": "820", "budget_aud": 4500, "returning_client": false }
    },
    {
      "id": "lead_087",
      "updated_at": "2026-09-18T09:31:40Z",
      "deleted": true
    }
  ],
  "next_cursor": null,
  "feed": { "name": "Portal enquiries", "version": "1" }
}

An error:

HTTP
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{ "error": { "code": "unauthorized", "message": "Token is unknown or revoked" } }

14. JSON Schema

The JSON Schema (draft 2020-12) for the Lead object, the page, the health answer and the error body is at schema.json. Tern validates with the same rules in code (leadFeedLeadSchema and leadFeedPageSchema in @tern/connectors); a test keeps this page, the JSON Schema and the code in step.

15. Conformance checklist

  • Served over HTTPS (TLS 1.2 or newer) on port 443 at a public host; no redirects.
  • GET /health answers { "ok": true, "version": "1" } and checks the token.
  • GET /leads checks the bearer token and answers 401 with an error body when it is wrong.
  • If a signing secret is issued: X-Tern-Signature is verified over <timestamp>.GET.<path_and_query> in constant time with a 300 second window.
  • updated_since is inclusive and understood with any offset.
  • limit from 1 to 500 is honoured, default 100, anything else is 400 invalid_limit.
  • data is ordered by updated_at then id, both ascending, stable across pages.
  • next_cursor is opaque, null on the last page, valid for 24 hours; an unknown cursor is 400 invalid_cursor.
  • Every Lead has a stable id, an updated_at with an offset and at least one of email, phone or whatsapp (or deleted: true).
  • updated_at changes whenever anything in the Lead changes, including deletion.
  • Phones are E.164 or national numbers for the lead's country or the feed's default region.
  • attributes is flat: strings, numbers, booleans or null only.
  • Removed leads are sent with deleted: true.
  • Each page is well under 10 MB and answers within 20 seconds.
  • 429 carries Retry-After; every error carries { "error": { "code", "message" } } with no secrets in it.
  • Only the tenant's own leads are reachable with the tenant's token.