Developer guide
OFAC API in PHP: onboarding with cURL
Add an OFAC API check to a PHP onboarding form with cURL. Keep the key on the server and link SDN results to the customer’s review.

On this page
Screen OFAC subjects from a PHP application
Keep the cURL request in a backend service used by the authenticated controller or queue worker. Validate the legal subject and selected coverage before creating the operation. Store the provider key in server-side configuration and avoid passing it through a form, query string or browser response.
When adapting the example to a framework such as Laravel or Symfony, retain the same failure semantics. A framework HTTP helper may represent HTTP errors differently from cURL. Confirm the status and response shape explicitly rather than assuming successful decoding means the screening completed.
Prepare the synthetic exercise
Save the sandbox request beside the PHP script and call it from a trusted command line before wiring an onboarding form. The invented person lets you inspect the controller response without copying a customer into a preview. The production credential belongs in backend configuration, never in a hidden form field.
| Requirement | Value or action |
|---|---|
| Runtime | Use PHP 8.2 or later with the cURL extension enabled. Run the script in a backend or trusted CLI. This example does not require Composer or a provider SDK. |
| Client file | Save the client as screen.php |
| Screening endpoint | POST /api/v1/screenings |
| SANCTIONSKIT_API_KEY | Sandbox key; screenings:write and results:read scopes |
| SCREENING_OPERATION_ID | Persist one operation ID; reuse only with the identical request body |
| request.json | Mira Calder: invented subject; sandbox@1: synthetic records |
{
"subject": {
"name": "Mira Calder",
"entityType": "person",
"birthDate": "1984"
},
"package": "sandbox@1",
"retention": "standard"
}Implement the PHP request boundary
The cURL example checks HTTP status separately from JSON decoding. Preserve both checks when moving it into Laravel or Symfony: a decoded error body is still a failed screening. Keep the user-facing form error separate from the private provider diagnostics.
<?php
// PHP 8.2+, ext-curl. Run in a backend or trusted CLI, never in browser code.
declare(strict_types=1);
try {
$key = getenv('SANCTIONSKIT_API_KEY') ?: '';
$operation = getenv('SCREENING_OPERATION_ID') ?: '';
if ($key === '' || $operation === '') throw new RuntimeException('Set the API key and a persistent operation ID.');
$raw = file_get_contents($argv[1] ?? 'request.json');
if ($raw === false || strlen($raw) > 65536) throw new RuntimeException('Request file is missing or too large.');
$body = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($body) || !isset($body['subject']) || (array_key_exists('sources', $body) === array_key_exists('package', $body))) {
throw new RuntimeException('Supply a subject and exactly one of sources or package.');
}
$received = '';
$curl = curl_init('https://www.sanctionskit.com/api/v1/screenings');
if ($curl === false) throw new RuntimeException('Unable to initialize HTTP client.');
curl_setopt_array($curl, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
CURLOPT_HTTPHEADER => ['Authorization: Bearer '.$key, 'Content-Type: application/json', 'Idempotency-Key: '.$operation],
CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 25,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$received): int {
if (strlen($received) + strlen($chunk) > 8 * 1024 * 1024) return 0;
$received .= $chunk;
return strlen($chunk);
},
]);
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($ok === false) throw new RuntimeException('Transport failed or response exceeded size limit.');
$envelope = json_decode($received, false, 512, JSON_THROW_ON_ERROR);
if ($status < 200 || $status >= 300) throw new RuntimeException('Screening incomplete: HTTP '.$status.'.');
$data = is_object($envelope) ? ($envelope->data ?? null) : null;
if (!is_object($data) || !is_string($data->id ?? null) || $data->id === '' ||
!in_array($data->status ?? null, ['potential_match', 'no_match'], true) ||
!is_array($data->matches ?? null)) {
throw new RuntimeException('Incomplete response: unexpected screening shape.');
}
$count = count($data->matches);
if (($data->status === 'no_match' && $count !== 0) || ($data->status === 'potential_match' && $count === 0)) {
throw new RuntimeException('Incomplete response: inconsistent screening shape.');
}
echo json_encode(['id' => $data->id, 'status' => $data->status, 'candidateCount' => $count], JSON_THROW_ON_ERROR).PHP_EOL;
} catch (Throwable $error) {
// No raw response, submitted identity, or credential is logged.
fwrite(STDERR, 'Screening did not complete: '.get_class($error).'. Check the operation before retrying.'.PHP_EOL);
exit(1);
}Run the client
php screen.php request.jsonConfirm OFAC scope before production
A PHP form labeled OFAC should say whether it requests ofac-sdn alone or also requires non-SDN sources. The sample selects SDN. Validate source availability in the backend before enabling submission, and keep required coverage fixed when an upstream list is unavailable.
| Check | Required evidence |
|---|---|
| GET /api/v1/sources | Production key with sources:read; response data array |
| id | ofac-sdn |
| availability / fresh | available / true |
| capabilities | Contains the requested subject.entityType |
| currentVersion | Active version; retrieved_at and last_checked_at are not publication dates |
| lastImport | May describe an import that is not the active version |
| Missing or ineligible source | Inspect disabledReason; required coverage stays incomplete |
| Displayed client | Add these discovery checks before its production call |
curl --fail-with-body --max-time 25 --header "Authorization: Bearer $SANCTIONSKIT_API_KEY" "https://www.sanctionskit.com/api/v1/sources"Select ofac-sdn in the production request
| Setting | Production request |
|---|---|
| Key | Separate production credential with screenings:write |
| Coverage | sources: ["ofac-sdn"]; omit package |
| Operation ID | New ID for the changed body and environment |
| Usage | Production access and screening allowance apply |
| Subject | Only lawfully held, known identifying facts; no invented date precision |
{
"subject": {
"name": "Mira Calder",
"entityType": "person",
"birthDate": "1984"
},
"sources": [
"ofac-sdn"
],
"retention": "standard"
}Interpret OFAC evidence in this workflow
Use data.id as the link from the form submission to retained screening evidence. A controller can return a review-required state for data.matches without embedding raw names and identifiers in its error message. data.coverage records the actual source used by that operation.
| Fields in data.matches[] | Review use |
|---|---|
| record.sourceId and record.id | Keep the SDN source and stable record reference attached to the candidate. |
| record.designations[].programs and legalReferences | Show the listed program and legal references; do not turn every non-SDN restriction into an asset freeze. |
| record.names, identifiers and birthDates | Compare the supplied identity with aliases and available identifiers, preserving partial-date precision. |
- data.id; data.coverage; data.versions; supplied disclaimer
- data.matches[]: record, assessment, evidence, conflicts
- GET /api/v1/results/{id}: results:read scope
- Retained evidence and case review
Verify the application behavior
| Condition | Next action |
|---|---|
| 401 / 403 | Correct credentials or scopes before retrying |
| coverage_unavailable / stale_coverage | Keep required coverage incomplete; no silent source substitution |
| Timeout after submission | Recover with the original Idempotency-Key and identical body |
| 409 | Investigate an operation-key conflict |
| 429 / 503 | Follow the specific error and Retry-After; an allowance cap may require a plan or usage change |
Contract and safe diagnostic references
| Reference | Use |
|---|---|
| error.code / error.requestId | Safe recovery diagnostics; never log the submitted subject, key or full candidate response |
| potential_match / no_match | Completed comparison outcomes; neither is an automatic business approval |
- Return valid JSON with an HTTP 403 and confirm the controller rejects it.
- Retry a timed-out form submission with the stored operation key.
- Check the rendered form and browser response for leaked credentials or candidate evidence.
Sources and references
Authority and technical sources
SanctionsKit documentation
Frequently asked questions
Which PHP extension does the OFAC example require?
Use PHP 8.2 or later with cURL enabled. No Composer package is required for the displayed script. If a framework wraps cURL, retain explicit HTTP-status and response-contract checks in the service boundary.
Can a PHP form let the user skip an unavailable OFAC list?
Only a deliberate, authorized policy change should alter required sources. A submit handler must not silently delete the missing list and return success under the old coverage label.
Can I copy this OFAC request into Laravel or Symfony?
Yes, but check how your framework handles HTTP errors compared with cURL. Validate the status and response shape before treating a check as complete. Keep the key server-side and validate the submitted identity and permitted coverage.
Why can an OFAC response decode successfully but still fail in PHP?
Error responses are also JSON. Check the HTTP code and expected screening fields before reading the outcome. An error object or missing coverage must remain incomplete rather than becoming an empty list of matches.