Example file

send-lead.php

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

send-lead.php · PHP Download
<?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;
    }
}

Stuck, or something missing?

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

Contact us