Example file

send-lead.mjs

Node: gets a token, sends a lead with an Idempotency-Key, and checks what became of it.

send-lead.mjs · JavaScript (Node) Download
// 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)
})

Stuck, or something missing?

Get in touch and a person on the team will help.

Contact us