Example file
send-lead.mjs
Node: gets a token, sends a lead with an Idempotency-Key, and checks what became of it.
// Sends one lead to a Subcontext campaign from your server, then checks what
// became of it.
//
// It gets an access token with a server-only API
// application's client id and secret, then sends the lead to the leads API
// with an Idempotency-Key, so a retry can't make a second lead.
// Run this on your server, never in a web page, because it holds the secret.
//
// SUBCONTEXT_CLIENT_ID=... SUBCONTEXT_CLIENT_SECRET=... SUBCONTEXT_CAMPAIGN_KEY=... node send-lead.mjs
//
// SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET: from the Subcontext
// dashboard, Integrations, API applications. Use an application with no
// allowed origins (server-only) and the read, write and crm scopes ticked.
// The client secret is shown only once, when you create the application.
// SUBCONTEXT_CAMPAIGN_KEY: the campaign the lead belongs to. In the dashboard,
// CRM, Campaigns, open the campaign. Its key is under its name.
// SUBCONTEXT_BASE_URL (optional): the Subcontext address. Default
// https://subcontext.com.
//
// This sends a real lead. Put your own name and email address in LEAD below.
// Needs Node 18 or later and no packages.
import { randomUUID } from 'node:crypto'
const baseUrl = (process.env.SUBCONTEXT_BASE_URL || 'https://subcontext.com').replace(/\/+$/, '')
const clientId = process.env.SUBCONTEXT_CLIENT_ID
const clientSecret = process.env.SUBCONTEXT_CLIENT_SECRET
const campaignKey = process.env.SUBCONTEXT_CAMPAIGN_KEY
// The lead. Replace these with your own details while you try it.
const LEAD = {
// Your system's name, so you can tell where leads came from. Lowercase
// letters, numbers, "_", "." and "-", up to 64 characters.
source: 'my_website',
campaign: campaignKey,
// Who they are. An email, a phone number or your own reference is needed to
// recognise the same person next time.
identity: {
first_name: 'YOUR_FIRST_NAME',
last_name: 'YOUR_LAST_NAME',
email: 'you@example.com',
// Your own reference for this person, if you have one.
external_id: 'your-ref-0001'
},
// Only what the person agreed to. Leave a channel out if they didn't.
consent: {
service: { email: true },
method: 'web_form',
given_at: new Date().toISOString()
},
// Where they came from. Subcontext reads the utm tags from page_url.
provenance: {
page_url: 'https://www.example.com/contact?utm_source=newsletter',
referrer: 'https://www.example.com/'
},
// Anything else, as free text. Read by Subcontext's AI to find details.
text: 'Asked about cover for a new mortgage.'
}
// One key per lead. Keep it with your own record of the lead and send the same
// key again if you retry, so Subcontext knows it's the same lead.
const idempotencyKey = randomUUID()
async function getToken() {
const response = await fetch(baseUrl + '/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
// write and crm are both needed to send leads.
scope: 'read write crm',
// What the token is for (the Subcontext API). Required.
resource: baseUrl + '/api/v1'
})
})
const body = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error('Token request failed (' + response.status + '): ' + (body.error_description || body.error || 'no details'))
}
// A real server would keep this token and reuse it until shortly before
// expires_in runs out (see get-token.mjs). One lead only needs one token.
return body.access_token
}
// Sends the lead. Tries once more, with the same Idempotency-Key, if the
// connection fails or Subcontext has a problem on its side (a 5xx status).
async function sendLead(token) {
for (let attempt = 1; ; attempt++) {
try {
const response = await fetch(baseUrl + '/api/v1/crm/ingests', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + token,
'Content-Type': 'application/json',
Accept: 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(LEAD)
})
if (response.status >= 500 && attempt < 2) continue
return response
} catch (error) {
if (attempt >= 2) throw error
}
}
}
async function main() {
if (!clientId || !clientSecret || !campaignKey) {
throw new Error('Set SUBCONTEXT_CLIENT_ID, SUBCONTEXT_CLIENT_SECRET and SUBCONTEXT_CAMPAIGN_KEY (see the comment at the top of this file).')
}
const token = await getToken()
console.log('Got a token. Sending the lead with Idempotency-Key', idempotencyKey)
const response = await sendLead(token)
const body = await response.json().catch(() => ({}))
if (response.status !== 202) {
// Errors look like { "error": { "code": "...", "message": "...", "fields": { ... } } }.
// A 401 or a 403 can have no body. See https://subcontext.com/developers/oauth/reference#errors-and-fixes.
const error = body.error || {}
throw new Error('The lead was not accepted (' + response.status + '): ' + (error.code || '') + ' ' + (error.message || ''))
}
// 202 means Subcontext has stored the lead. It is processed in the
// background, and the Location header is where to ask what became of it.
const ingest = body.data
console.log('Accepted.', ingest.duplicate ? '(Subcontext already had this one: no second lead was made.)' : '')
console.log(' id:', ingest.id)
console.log(' status:', ingest.status, '-', ingest.explanation)
const location = response.headers.get('Location')
for (let check = 0; check < 5 && location; check++) {
await new Promise((resolve) => setTimeout(resolve, 2000))
const poll = await fetch(new URL(location, baseUrl), { headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' } })
if (!poll.ok) break
const now = (await poll.json()).data
console.log(' status now:', now.status, '-', now.explanation)
if (!['received', 'resolving', 'extracting'].includes(now.status)) break
}
}
main().catch((error) => {
console.error(error.message)
process.exit(1)
})