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:
- The platform (the provider) serves
GET {base_url}/healthandGET {base_url}/leadsas described below. - The provider issues the tenant a token.
- The tenant pastes the base URL and token into Tern, clicks Test connection and saves the feed under a name such as "Portal enquiries".
- 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
localhostor*.internal. - The base URL may carry a path. With
https://api.example.com/tern/v1Tern callshttps://api.example.com/tern/v1/healthandhttps://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
GETwithAccept: application/jsonandUser-Agent: Tern-LeadFeed/1. Responses must be JSON in UTF-8 withContent-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:
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).
3.2 Request signing (optional, recommended)
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:
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:
<timestamp>.<method>.<path_and_query>timestampis the value ofX-Tern-Timestamp.methodis the upper case HTTP method, alwaysGET.path_and_queryis 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:
{ "ok": true, "version": "1" }| Field | Type | Notes |
|---|---|---|
ok | boolean | true when the feed can serve leads. |
version | string | Always "1" for this version. |
4.2 GET {base_url}/leads
Query parameters:
| Parameter | Type | Notes |
|---|---|---|
updated_since | ISO 8601 date and time | Inclusive: return leads whose updated_at is greater than or equal to it. Absent on the first ever run, meaning "everything". |
cursor | string | Opaque value from the previous page's next_cursor. Absent on the first page. |
limit | integer, 1 to 500 | Page 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):
{
"data": [ /* Lead objects, section 5 */ ],
"next_cursor": "eyJ1IjoiMjAyNi0wOS0xOFQwOToyOTo1OFoiLCJpIjoibGVhZF8xMDMifQ",
"feed": { "name": "Portal enquiries", "version": "1" }
}| Field | Type | Notes |
|---|---|---|
data | array of Lead | At most limit items, ordered as section 6 describes. May be empty. |
next_cursor | string or null | Opaque cursor for the next page, at most 2048 characters. null on the last page. |
feed | object | feed.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
| Field | Type | Notes |
|---|---|---|
id | string, 1 to 255 chars | Stable id of the lead in your system. Never reuse an id for a different person. |
updated_at | ISO 8601 with offset | When 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
| Field | Type | Notes |
|---|---|---|
email | string | Up to 320 characters. Compared case insensitively. |
phone | string | Up to 64 characters. See section 5.5. |
whatsapp | string | WhatsApp number when it differs from phone. Same rules. |
5.3 Optional
| Field | Type | Notes |
|---|---|---|
name | string | Full name. Used as is; split into first and last when those are absent. |
first_name | string | Up to 200 characters. |
last_name | string | Up to 200 characters. |
company | string | Up to 200 characters. |
country | string | ISO 3166-1 alpha-2 code such as AU. Also the region for national phone numbers. |
timezone | string | IANA zone such as Australia/Sydney. An unrecognised zone is ignored; the rest of the lead is kept. |
stage | string | Free text stage or status in your system, up to 100 characters. Kept as the provider stage; Tern derives its own. |
owner_email | string (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. |
notes | string | Up to 10,000 characters. |
tags | array of strings | Up to 50 tags of 1 to 64 characters. Added to the lead's labels. |
source_detail | string | Where the lead came from in your system, for example a campaign or form name. |
created_at | ISO 8601 with offset | When the lead was created in your system. |
consent | object | See section 5.4. |
attributes | object | Flat 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. |
deleted | boolean | true marks the lead as removed in your system. See section 7. |
5.4 consent
| Field | Type | Notes |
|---|---|---|
marketing | boolean | The lead agreed to marketing messages. |
whatsapp | boolean | The lead agreed to be contacted on WhatsApp. |
recorded_at | ISO 8601 with offset | When 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 678is read with the lead'scountry, or with the feed's default region (set by the tenant in Tern,AUunless changed) whencountryis 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
| Field | In Tern |
|---|---|
email, phone, whatsapp | Identifiers. 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_name | Lead name. |
tags | Lead labels (added, never removed by a sync). |
owner_email | Owner, when a Tern user in the workspace has that email. |
stage | Kept as the provider stage attribute. |
consent | consent_marketing, consent_whatsapp and consent_recorded_at attributes. |
company, country, notes, source_detail, created_at, attributes | Lead attributes. |
timezone | Lead 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
databyupdated_atascending, then byidascending (plain string comparison). The order must be stable across pages. updated_atmust change whenever any field of the Lead changes, includingdeleted.- Cursors are opaque to Tern. A keyset cursor that encodes the
updated_atandidof 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
400with codeinvalid_cursor; Tern then restarts fromupdated_since. - Tern follows
next_cursoruntil it isnull, one request at a time. - After a run Tern stores the greatest
updated_atit read (its high water mark). The next run asks forupdated_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
idand 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_cursorand resumes from it on the next run. - A record that fails validation is skipped and reported to the tenant with its
idand a reason; the rest of the page is processed normally. To fix it, correct the record and bump itsupdated_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 honoursRetry-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
4xxanswers are not retried.401and403are shown to the tenant as a rejected token.
9. Errors
Every non 2xx response carries:
{ "error": { "code": "invalid_cursor", "message": "cursor is not recognised" } }| Field | Type | Notes |
|---|---|---|
error | object | Holds code and message. |
error.code | string | Short machine readable code, up to 100 characters. |
error.message | string | Human 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
notesandattributes.
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-Signaturecheck (verifyTernSignature); - filtering on
updated_since, ordering byupdated_atthenid; - a keyset cursor (base64url of the last
updated_atandid); limitvalidation and error bodies.
The signature check in Node, for reference:
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:
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: 8127883fb956f19858e6a32c41da6a35c7f81deef000d8815b6faa84f910d6dbResponse:
HTTP/1.1 200 OK
Content-Type: application/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/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 /healthanswers{ "ok": true, "version": "1" }and checks the token.GET /leadschecks the bearer token and answers401with an error body when it is wrong.- If a signing secret is issued:
X-Tern-Signatureis verified over<timestamp>.GET.<path_and_query>in constant time with a 300 second window. updated_sinceis inclusive and understood with any offset.limitfrom 1 to 500 is honoured, default 100, anything else is400 invalid_limit.datais ordered byupdated_atthenid, both ascending, stable across pages.next_cursoris opaque,nullon the last page, valid for 24 hours; an unknown cursor is400 invalid_cursor.- Every Lead has a stable
id, anupdated_atwith an offset and at least one ofemail,phoneorwhatsapp(ordeleted: true). updated_atchanges whenever anything in the Lead changes, including deletion.- Phones are E.164 or national numbers for the lead's
countryor the feed's default region. attributesis 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.
429carriesRetry-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.