Example file
send-lead.php
PHP: gets a token, sends a lead with an Idempotency-Key, and checks what became of it.
<?php
// 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=... php send-lead.php
//
// 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 PHP 8.1 or later and no extensions beyond the standard ones.
$baseUrl = rtrim(getenv('SUBCONTEXT_BASE_URL') ?: 'https://subcontext.com', '/');
$clientId = getenv('SUBCONTEXT_CLIENT_ID');
$clientSecret = getenv('SUBCONTEXT_CLIENT_SECRET');
$campaignKey = getenv('SUBCONTEXT_CAMPAIGN_KEY');
if (!$clientId || !$clientSecret || !$campaignKey) {
fwrite(STDERR, "Set SUBCONTEXT_CLIENT_ID, SUBCONTEXT_CLIENT_SECRET and SUBCONTEXT_CAMPAIGN_KEY (see the comment at the top of this file).\n");
exit(1);
}
// The lead. Replace these with your own details while you try it.
$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' => gmdate('Y-m-d\TH:i:s\Z'),
],
// 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.
$bytes = random_bytes(16);
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
$idempotencyKey = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
// Sends a request and returns [HTTP status, decoded JSON body, response headers].
// $status is 0 if Subcontext couldn't be reached.
function subcontext_http(string $method, string $url, array $headers, ?string $content = null): array
{
$context = stream_context_create(['http' => [
'method' => $method,
'header' => implode("\r\n", array_merge($headers, ['Accept: application/json'])) . "\r\n",
'content' => $content ?? '',
'timeout' => 20,
'ignore_errors' => true, // read the body of error answers too
]]);
$raw = @file_get_contents($url, false, $context);
if ($raw === false) {
return [0, [], []];
}
$responseHeaders = function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : $http_response_header;
$status = preg_match('#^HTTP/\S+ (\d{3})#', $responseHeaders[0] ?? '', $match) ? (int) $match[1] : 0;
return [$status, json_decode($raw, true) ?: [], $responseHeaders];
}
function response_header(array $headers, string $name): ?string
{
foreach ($headers as $line) {
if (stripos($line, $name . ':') === 0) {
return trim(substr($line, strlen($name) + 1));
}
}
return null;
}
// 1. A token. A real server would keep it and reuse it until shortly before
// expires_in runs out (see get-token.php). One lead only needs one token.
[$status, $body] = subcontext_http('POST', $baseUrl . '/oauth/token', ['Content-Type: application/x-www-form-urlencoded'], http_build_query([
'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',
]));
if ($status !== 200) {
fwrite(STDERR, 'Token request failed (' . $status . '): ' . ($body['error_description'] ?? $body['error'] ?? 'no details') . "\n");
exit(1);
}
$token = $body['access_token'];
echo "Got a token. Sending the lead with Idempotency-Key $idempotencyKey\n";
// 2. 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).
$headers = [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey,
];
for ($attempt = 1; $attempt <= 2; $attempt++) {
[$status, $body, $responseHeaders] = subcontext_http('POST', $baseUrl . '/api/v1/crm/ingests', $headers, json_encode($lead));
if ($status !== 0 && $status < 500) {
break;
}
}
if ($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.
$error = $body['error'] ?? [];
fwrite(STDERR, 'The lead was not accepted (' . $status . '): ' . ($error['code'] ?? '') . ' ' . ($error['message'] ?? '') . "\n");
exit(1);
}
// 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.
$ingest = $body['data'];
echo 'Accepted.', $ingest['duplicate'] ? ' (Subcontext already had this one: no second lead was made.)' : '', "\n";
echo ' id: ', $ingest['id'], "\n";
echo ' status: ', $ingest['status'], ' - ', $ingest['explanation'], "\n";
// 3. What became of it.
$location = response_header($responseHeaders, 'Location');
for ($check = 0; $check < 5 && $location; $check++) {
sleep(2);
[$status, $body] = subcontext_http('GET', $baseUrl . $location, ['Authorization: Bearer ' . $token]);
if ($status !== 200) {
break;
}
$now = $body['data'];
echo ' status now: ', $now['status'], ' - ', $now['explanation'], "\n";
if (!in_array($now['status'], ['received', 'resolving', 'extracting'], true)) {
break;
}
}