← Partner integration overview
Copy this file into your project and call Abcpro::reportSale(). It handles the HMAC signing, retries, and refunds. · Read the full contract →
<?php
/**
* ════════════════════════════════════════════════════════════════════════════
* AbcPro Connector — drop this ONE file into any project (GigPeach, GuestBookit,
* BluSpots, future). It is the entire integration surface.
* ════════════════════════════════════════════════════════════════════════════
*
* It does two things:
* 1. abcpro_capture_ref() — call on every page: reads ?ref=CODE, cookies it.
* 2. abcpro_report_sale() — call on checkout success: signs + POSTs the sale
* to the hub. Best-effort with a local retry queue, so the hub being down
* NEVER breaks the product's checkout.
*
* Configure via the product's environment (or define before requiring this):
* ABCPRO_URL e.g. https://abcpro.com
* ABCPRO_PROJECT_KEY e.g. gigpeach
* ABCPRO_KEY_ID the api key id issued by the hub
* ABCPRO_SECRET the shared HMAC secret
* ABCPRO_QUEUE_FILE (optional) path for the local retry queue (JSON lines)
*
* No database and no framework required in the product — just this file + curl.
*/
declare(strict_types=1);
if (!function_exists('abcpro_cfg')) {
function abcpro_cfg(string $key, ?string $default = null): ?string {
if (defined($key)) return (string)constant($key);
$v = getenv($key);
return ($v === false || $v === '') ? $default : $v;
}
}
/** The ?ref cookie name shared across a product's pages. */
if (!defined('ABCPRO_REF_COOKIE')) define('ABCPRO_REF_COOKIE', 'abcpro_ref');
/**
* Capture a referral code from the URL (?ref=CODE) into a long-lived cookie so
* it survives the journey to checkout. Returns the active ref code (or '').
*
* The cookie is set effectively permanent (10 years). Browsers cap cookies at
* ~400 days, but that cap doesn't limit the RELATIONSHIP: the moment the visitor
* buys or signs up, AbcPro binds them to this GC permanently (first-sponsor-wins,
* never expires). The cookie is only the bridge before that first action.
*/
function abcpro_capture_ref(): string {
$ref = '';
if (!empty($_GET['ref'])) {
$ref = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_GET['ref']));
if ($ref !== '' && !headers_sent()) {
setcookie(ABCPRO_REF_COOKIE, $ref, [
'expires' => time() + 3650 * 86400, // ~10 years (browser may clamp to ~400 days)
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
}
}
if ($ref === '' && !empty($_COOKIE[ABCPRO_REF_COOKIE])) {
$ref = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_COOKIE[ABCPRO_REF_COOKIE]));
}
return $ref;
}
/** The current ref code without touching cookies (read-only). */
function abcpro_current_ref(): string {
if (!empty($_COOKIE[ABCPRO_REF_COOKIE])) {
return strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_COOKIE[ABCPRO_REF_COOKIE]));
}
return !empty($_GET['ref']) ? strtoupper(preg_replace('/[^A-Za-z0-9]/', '', (string)$_GET['ref'])) : '';
}
/**
* Report a sale (or refund/chargeback) to the hub. Non-blocking philosophy:
* on any failure the payload is appended to a local queue for later retry and
* the function returns false — it never throws into the checkout flow.
*
* @param array $sale {
* external_id (required), gross_cents, base_cents?, currency?,
* buyer_email, buyer_name?, buyer_external_id?, ref_code?, occurred_at?,
* clears_at?, meta?, overrides?, event? ('sale'|'refund'|'chargeback')
* }
* @return bool true if the hub accepted it (HTTP 2xx)
*/
function abcpro_report_sale(array $sale): bool {
$hub = rtrim((string)abcpro_cfg('ABCPRO_URL', ''), '/');
$keyId = (string)abcpro_cfg('ABCPRO_KEY_ID', '');
$secret = (string)abcpro_cfg('ABCPRO_SECRET', '');
if ($hub === '' || $keyId === '' || $secret === '') {
abcpro_queue($sale, 'unconfigured');
return false;
}
// Default the ref code to the captured cookie if the caller didn't pass one.
if (empty($sale['ref_code'])) {
$r = abcpro_current_ref();
if ($r !== '') $sale['ref_code'] = $r;
}
if (empty($sale['project_key'])) $sale['project_key'] = abcpro_cfg('ABCPRO_PROJECT_KEY', '');
// Stamp a monotonic report SEQUENCE number the FIRST time this report is built,
// so the hub can detect a dropped report as a gap. It rides through the retry
// queue unchanged, so a queued+flushed report keeps its original number.
// A project with its own DB can pass $sale['seq'] itself (a stable row id);
// otherwise we use a durable local counter.
if (!isset($sale['seq'])) $sale['seq'] = abcpro_next_seq();
$body = json_encode($sale, JSON_UNESCAPED_SLASHES);
$ts = time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $secret);
$ch = curl_init($hub . '/api/v1/sale.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 6,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-AbcPro-Key: ' . $keyId,
'X-AbcPro-Timestamp: ' . $ts,
'X-AbcPro-Signature: ' . $sig,
],
]);
$resp = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300) return true;
// Hub unreachable or rejected → queue for retry, never break checkout.
abcpro_queue($sale, 'http_' . $code . ($resp ? ':' . substr((string)$resp, 0, 200) : ''));
return false;
}
/** Convenience: report a refund/chargeback by the product's external id. */
function abcpro_report_refund(string $externalId, string $type = 'refund'): bool {
return abcpro_report_sale(['event' => $type, 'external_id' => $externalId]);
}
/**
* Durable, monotonic report counter for this project. Atomic under concurrency
* via an exclusive file lock. Stored next to the queue file so it survives
* restarts. (Multi-server products should instead pass their own stable seq.)
*/
function abcpro_next_seq(): int {
$qf = (string)abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl');
$file = (string)abcpro_cfg('ABCPRO_SEQ_FILE', preg_replace('/\.jsonl$/', '', $qf) . '.seq');
$fh = @fopen($file, 'c+');
if (!$fh) return (int)(microtime(true) * 1000); // last-resort: time-based, still monotonic-ish
@flock($fh, LOCK_EX);
$cur = (int)stream_get_contents($fh);
$next = $cur + 1;
rewind($fh); ftruncate($fh, 0); fwrite($fh, (string)$next); fflush($fh);
@flock($fh, LOCK_UN); fclose($fh);
return $next;
}
/**
* Send a signed MANIFEST — the project's own independent tally — so the hub can
* cross-check it against what it received (gap detection + total match). Call
* from the product's cron (e.g. hourly). The project computes these from its OWN
* database:
* seq_high the highest report number it has sent (abcpro current seq)
* report_count how many reports it has sent in total
* gross_total_cents (optional) the project's own gross total for those reports
*
* @return bool true on HTTP 2xx
*/
function abcpro_report_manifest(int $seqHigh, int $reportCount, ?int $grossTotalCents = null): bool {
$hub = rtrim((string)abcpro_cfg('ABCPRO_URL', ''), '/');
$keyId = (string)abcpro_cfg('ABCPRO_KEY_ID', '');
$secret = (string)abcpro_cfg('ABCPRO_SECRET', '');
if ($hub === '' || $keyId === '' || $secret === '') return false;
$payload = ['seq_high' => $seqHigh, 'report_count' => $reportCount, 'as_of' => date('c')];
if ($grossTotalCents !== null) $payload['gross_total_cents'] = $grossTotalCents;
$body = json_encode($payload, JSON_UNESCAPED_SLASHES);
$ts = time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $secret);
$ch = curl_init($hub . '/api/v1/manifest.php');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-AbcPro-Key: ' . $keyId,
'X-AbcPro-Timestamp: ' . $ts,
'X-AbcPro-Signature: ' . $sig,
],
]);
curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code >= 200 && $code < 300;
}
/** The current high-water report number (without incrementing). For manifests. */
function abcpro_current_seq(): int {
$qf = (string)abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl');
$file = (string)abcpro_cfg('ABCPRO_SEQ_FILE', preg_replace('/\.jsonl$/', '', $qf) . '.seq');
return is_readable($file) ? (int)file_get_contents($file) : 0;
}
/**
* Push this product's catalog to AbcPro so its offerings appear in every Growth
* Consultant's storefront. Call it whenever items change, or from a cron.
*
* Each item: [ 'external_item_id'=>'event:501', 'title'=>'…', 'buy_url'=>'https://…',
* 'price_cents'=>2500, 'description'=>'…', 'image_url'=>'…', 'category'=>'…', 'status'=>'active' ]
* (external_item_id, title, buy_url are required.)
*
* @param bool $replaceAll if true, AbcPro deactivates any of this product's items not in $items.
* @return bool true on HTTP 2xx
*/
function abcpro_push_catalog(array $items, bool $replaceAll = false): bool {
$hub = rtrim((string)abcpro_cfg('ABCPRO_URL', ''), '/');
$keyId = (string)abcpro_cfg('ABCPRO_KEY_ID', '');
$secret = (string)abcpro_cfg('ABCPRO_SECRET', '');
if ($hub === '' || $keyId === '' || $secret === '') return false;
$body = json_encode(['items' => array_values($items), 'replace_all' => $replaceAll], JSON_UNESCAPED_SLASHES);
$ts = time();
$sig = hash_hmac('sha256', $ts . '.' . $body, $secret);
$ch = curl_init($hub . '/api/v1/catalog.php');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-AbcPro-Key: ' . $keyId,
'X-AbcPro-Timestamp: ' . $ts,
'X-AbcPro-Signature: ' . $sig,
],
]);
curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code >= 200 && $code < 300;
}
/** Append a failed payload to the local retry queue (JSON lines). */
function abcpro_queue(array $sale, string $reason): void {
$file = abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl');
$line = json_encode(['at' => date('c'), 'reason' => $reason, 'sale' => $sale], JSON_UNESCAPED_SLASHES);
@file_put_contents((string)$file, $line . "\n", FILE_APPEND | LOCK_EX);
}
/**
* Drain the local retry queue (call from the product's cron). Re-sends each
* queued payload; keeps the ones that still fail. Safe because the hub is
* idempotent on (project, external_id).
*/
function abcpro_flush_queue(): array {
$file = (string)abcpro_cfg('ABCPRO_QUEUE_FILE', sys_get_temp_dir() . '/abcpro_queue.jsonl');
if (!is_readable($file)) return ['sent' => 0, 'kept' => 0];
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
@unlink($file);
$sent = 0; $kept = 0;
foreach ($lines as $line) {
$row = json_decode($line, true);
if (!is_array($row) || empty($row['sale'])) continue;
if (abcpro_report_sale($row['sale'])) $sent++; else $kept++; // failure re-queues
}
return ['sent' => $sent, 'kept' => $kept];
}