PHP / Laravel Integration
Consume the Keyban Digital Product Passport REST API from a server-side PHP application. The samples use the Laravel HTTP client; they translate one-for-one to Guzzle or any PSR-18 client — only the headers, paths, payloads and status semantics shown here are Keyban-specific.
Overview
This guide is scoped to the parts of a PHP integration that are unique to Keyban:
- The auth model (
X-Api-Key,dpppermission, public read endpoint) - The passport lifecycle (
draft→published, irreversible) and how it interacts with certification - The discriminated-union body of
POST /v1/dpp/passportsand the unique-tuple idempotency strategy - The async bulk-import flow (
POST /v1/imports) for catalog migration and periodic syncs - Retryable status codes and the RFC 7807 problem+json error shape
- How
certifiedPathsdrives on-chain certification and re-certification
It does not cover generic PHP, Laravel or HTTP-client topics — refer to the framework documentation for those.
Prerequisites
- An API key and Application ID (UUID) from the Keyban Dashboard: Organization → Integrations → Applications.
- Base URL:
https://api.prod.keyban.io. Pre-production environments are reserved for the Keyban team — partners always integrate against production.
Authentication
X-Api-Key: <YOUR_API_KEY>
Permissions are scoped per organization. Your API key needs the dpp permission with the relevant action (read, create, update, delete). A 403 Forbidden means the key is valid but lacks the action.
The reading endpoint (GET /v1/dpp/passports/{id}) is public — no X-Api-Key required.
Quick start
Read the API key from the environment (e.g. KEYBAN_API_KEY, KEYBAN_APP_ID, KEYBAN_BASE_URL) and issue a request:
use Illuminate\Support\Facades\Http;
$passport = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->get("v1/dpp/passports/{$passportId}")
->throw()
->json();
Core operations
Create
POST /v1/dpp/passports — status starts at draft.
Draft
Free-form PATCH on any field except status. No on-chain effect.
updatePublished
Public, signed, on-chain certificate anchored.
re-certifies
Lifecycle in four steps:
- Create —
POST /v1/dpp/passportsreturns a passport withstatus = draft. Nothing happens on-chain at this stage; drafts live entirely in the Keyban database. - Update a draft —
PATCH /v1/dpp/passports/{id}is a free-form edit. Still no on-chain side effect. - Publish —
POST /v1/dpp/passports/publishmoves matching drafts topublished. Irreversible. Passports become publicly readable and the certificate (a signature over the values atcertifiedPaths) is anchored on-chain. - Update a published passport —
PATCHstill works on most fields. Mutating any value at acertifiedPathsentry, or changingcertifiedPathsitself, automatically re-certifies the passport: a newlastCertificateHashis stored and a new on-chain certification event is emitted.
The on-chain mint of an item passport (ERC-721 transfer to the end-user's wallet) is a separate event that fires later, when a consumer claims the passport — see Claim and on-chain mint.
Create a passport
POST /v1/dpp/passports returns the passport (status draft) immediately. No on-chain operation is triggered at this stage — drafts are stored only in the Keyban database. Certification anchoring happens later, on publish; the ERC-721 mint to a consumer wallet happens later still, on claim (see Claim and on-chain mint).
API reference:
POST /v1/dpp/passports
The body is a discriminated union on granularity:
model— product reference (template shared across all units of a SKU).batch— production lot. RequiresmodelNumberto link it to its parentmodel.item— serialized unit. RequiresmodelNumberandbatchNumberto link it to its parents.
Network identifiers come from the Network enum (e.g. StarknetSepolia, StarknetMainnet, StellarMainnet, PolygonAmoy, …).
$passport = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->asJson()
->post('v1/dpp/passports', [
'application' => getenv('KEYBAN_APP_ID'),
'granularity' => 'item',
'network' => 'StarknetMainnet',
'modelNumber' => 'XYZ-12',
'batchNumber' => '2026-W17',
'itemNumber' => 'SN123',
'name' => 'XYZ Mobile 12',
'description' => 'Sleek 5G smartphone, grey, 128 GB.',
'images' => [
['src' => 'https://cdn.example.com/xyz12.jpg', 'alt' => 'XYZ Mobile 12'],
],
'data' => [
'gtin' => '9783161484100',
'brand' => 'XYZ',
'color' => 'grey',
],
'certifiedPaths' => ['gtin', 'brand'], // dot-paths inside `data` to include in the signed certificate
])
->throw()
->json();
The tuple (application, granularity, modelNumber, batchNumber, itemNumber) is uniquely indexed: a duplicate create returns 409 Conflict. Use that as your natural idempotency key — derive modelNumber/batchNumber/itemNumber from your own product references and a retried call will fail loudly instead of duplicating.
tokenId is returned immediately, even on a draft: it is computed deterministically from the passport id (0x + SHA-256). It is not an indication that the token has been minted on-chain — mintedTo is the field to inspect for that.
Update a passport
PATCH /v1/dpp/passports/{id} partially updates a passport. The status field is not patchable — use the publish endpoint to move from draft to published. Fields you can update: modelNumber, batchNumber, itemNumber, name, description, images, data, actions, certifiedPaths, and allowedClaimEmail / allowedClaimPhoneNumber on item passports.
API reference:
PATCH /v1/dpp/passports/{id}
Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->asJson()
->patch('v1/dpp/passports/' . rawurlencode($id), [
'name' => 'XYZ Mobile 12 (refurbished)',
'data' => [
'gtin' => '9783161484100',
'brand' => 'XYZ',
'color' => 'grey',
'condition' => 'refurbished',
],
'certifiedPaths' => ['gtin', 'brand', 'condition'],
])
->throw()
->json();
Constraints:
editablemust betrue. Only passports sourced from Shopify (source = "shopify") are read-only and respond403 Forbidden. Passports created viaPOST /v1/imports(source = "import") remain editable throughPATCH, but the natural way to amend them is to re-upload the row throughPOST /v1/imports— the unique tuple triggers an upsert.- Changing
modelNumber/batchNumber/itemNumbercan violate the unique constraint on(application, granularity, modelNumber, batchNumber, itemNumber)and respond409 Conflict. - On a
publishedpassport, mutating any value at a certified path or changingcertifiedPathsitself triggers an automatic re-certification —lastCertificateHashis refreshed and a new on-chain event is emitted. See Data certification.
Publish a passport (irreversible)
POST /v1/dpp/passports/publish publishes every draft passport that matches the filters passed in the query string (same shape as the list endpoint), cascading the publish to the linked batches and items. The body is empty.
API reference:
POST /v1/dpp/passports/publish
Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->withQueryParameters([
'filters' => [
['field' => 'modelNumber', 'operator' => 'eq', 'value' => 'XYZ-12'],
],
])
->post('v1/dpp/passports/publish')
->throw()
->json(); // ['updatedCount' => 42]
The draft → published transition is irreversible. Published passports become publicly readable and trigger on-chain certification.
Read a passport (public)
$passport = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->acceptJson()
->get('v1/dpp/passports/' . rawurlencode($id))
->throw()
->json();
No auth header — safe to call from a public product page renderer.
API reference:
GET /v1/dpp/passports/{id}
List passports
The query format is Refine-compatible: currentPage / pageSize for pagination, filters[] and sorters[] as bracketed arrays serialized by qs.
$page = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->get('v1/dpp/passports', [
'currentPage' => 1, // 1-based, default 1
'pageSize' => 50, // default 10, max 100
'filters' => [
['field' => 'status', 'operator' => 'eq', 'value' => 'published'],
['field' => 'modelNumber', 'operator' => 'eq', 'value' => 'XYZ-12'],
],
'sorters' => [
['field' => 'createdAt', 'order' => 'desc'],
],
])
->throw()
->json();
// $page === ['data' => [...], 'total' => 1234]
Filterable fields: id, createdAt, updatedAt, application.id, status, granularity, modelNumber, batchNumber, itemNumber, name (contains), mintedTo.id. Operators are eq for most, contains for name, and gt|lt|gte|lte for date fields. Filter groups can be wrapped with {operator: 'and' | 'or', value: [...]}.
API reference:
GET /v1/dpp/passports
Bulk import (catalog migration)
For high-volume use cases — initial catalog migration, nightly ERP/PIM syncs — POST /v1/imports accepts a JSON array of passports and processes them asynchronously.
API reference:
POST /v1/imports
The body is a JSON array whose items match exactly the same CreateDppPassportDto discriminated union as the single-create endpoint (granularity: 'model' | 'batch' | 'item' with the corresponding required fields). The target application is passed as a query parameter, not in each item.
:::note PII fields and bulk import
Prefer setting allowedClaimEmail and allowedClaimPhoneNumber via a follow-up PATCH /v1/dpp/passports/{id} rather than including them in the import payload.
:::
The request must use Content-Type: application/json (Laravel's asJson() sets this for you) and the body must be a single JSON array of rows. The server parses the array as a stream, so very large payloads are supported without buffering the whole file in memory.
$rows = [
[
'granularity' => 'model',
'network' => 'StarknetMainnet',
'modelNumber' => 'XYZ-12',
'name' => 'XYZ Mobile 12',
'data' => ['gtin' => '9783161484100', 'brand' => 'XYZ'],
'certifiedPaths' => ['gtin', 'brand'],
],
[
'granularity' => 'item',
'network' => 'StarknetMainnet',
'modelNumber' => 'XYZ-12',
'batchNumber' => '2026-W17',
'itemNumber' => 'SN123',
'data' => ['gtin' => '9783161484100', 'brand' => 'XYZ', 'color' => 'grey'],
'certifiedPaths' => ['gtin', 'brand'],
],
// ... thousands more
];
$job = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->asJson()
->post('v1/imports?' . http_build_query([
'applicationId' => getenv('KEYBAN_APP_ID'),
'entityName' => 'DppPassport',
]), $rows)
->throw()
->json();
// $job === ['id' => '<uuid>', 'application' => '<uuid>',
// 'entityName' => 'DppPassport', 'source' => 'upload', ...]
The response is the ImportJob envelope (returned immediately) — it does not contain the imported passports. They are created in the background, one BullMQ child job per row.
Updating imported passports
Every row created through /v1/imports is persisted with source = "import". Those passports remain editable through PATCH /v1/dpp/passports/{id} (only source = "shopify" flips editable to false), but the natural way to amend them is to re-upload the row through the import endpoint. The unique tuple (application, granularity, modelNumber, batchNumber, itemNumber) triggers an upsert that merges name, description, images, data, actions and certifiedPaths onto the existing record. Identifiers and id are left untouched.
This makes the import endpoint the safe entry point for repeated syncs from an external system of record (PIM, ERP, Shopify): re-running the same export is a no-op when nothing changed, and a deterministic update otherwise.
Streaming progress (Server-Sent Events)
GET /v1/imports/{jobId}/progress is a Server-Sent Events stream that emits an ImportJobProgressDto snapshot whenever a child finishes:
{ "failed": 0, "ignored": 0, "processed": 142, "unprocessed": 858 }
Once the import finishes, the four counters (processed, failed, ignored, unprocessed) sum to the total number of rows in the upload. While the import is running, the counters reflect the current count of terminal vs non-terminal child jobs. The stream completes when unprocessed reaches zero.
API reference:
GET /v1/imports/{jobId}/progress
PHP servers rarely consume SSE directly — for batch workflows, poll the report endpoint instead (see below). Reserve the SSE stream for browser-side dashboards.
Per-row report
GET /v1/imports/{jobId}/report is a paginated endpoint that returns one entry per row, each with its own status. Use it to inspect failures after the job finishes:
$report = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->get('v1/imports/' . rawurlencode($jobId) . '/report', [
'currentPage' => 1,
'pageSize' => 100,
])
->throw()
->json();
foreach ($report['data'] as $child) {
// $child['status'] — JobStatus: completed | failed | active | delayed
// | prioritized | waiting | waiting-children
// | unknown
// $child['entityName'] — 'DppPassport'
// $child['entityData'] — the original row from the upload
// $child['result'] — the persisted passport (when status = 'completed')
// $child['error'] — failure reason (when status = 'failed')
}
The job is finished once every child is in a terminal state (completed or failed). Anything else (active, waiting, waiting-children, delayed, prioritized) means the worker has not reached this row yet.
API reference:
GET /v1/imports/{jobId}/report·GET /v1/imports/{jobId}·GET /v1/imports
Failure semantics
Validation runs per row, not on the whole upload:
- A malformed row (wrong granularity, missing
modelNumber, unknownnetwork, …) fails its own child job withstatus = "failed"and anerrormessage — the rest of the upload keeps processing. - The
POST /v1/importscall itself returns200as soon as the rows are queued, even if some will later fail. Treat the HTTP success only as "upload accepted"; correctness must be checked via the report. - A retried upload of the same rows is safe (upsert on the unique tuple), so a partially-failed import can be re-submitted as-is once the offending rows are fixed.
Data certification
Every published passport carries a cryptographic signature over a chosen subset of data. That subset is what consumers (and downstream verifiers) can prove was issued by your organization and has not been tampered with.
Two passport fields drive the mechanism:
| Field | Direction | Purpose |
|---|---|---|
certifiedPaths: string[] | client-set | Dot-notation paths inside data to include in the certificate (e.g. ["gtin", "brand", "manufacturer.name"]). When the array is empty, the entire data object is certified. |
lastCertificateHash: string | null | server-set, read-only | SHA-256 of the canonical JSON of the most recent certified content. Changes whenever the certificate is regenerated. |
Lifecycle
- On publish, the server canonicalises
datarestricted tocertifiedPaths, signs it with the organization's active certification key, and stores the resulting hash inlastCertificateHash. The signature itself is anchored on-chain. - On a published passport, updating
certifiedPathsor any value at one of the certified paths triggers an automatic re-certification server-side.lastCertificateHashis refreshed. - Removing a path from
certifiedPathsdoes not invalidate past certificates — they remain verifiable against the historical certification key. Choose the certified paths deliberately: every change is a new on-chain event.
Choosing certifiedPaths
Include only the values that are factual and stable:
- Include Identifiers and physical attributes:
gtin,serial,manufactureDate,material,weight. - Include Compliance fields you are willing to anchor:
originCountry,recyclability, certifications. - Skip Marketing copy (
description), images (covered byimages+ content-addressed storage), business metadata that can change without affecting the product (stock,priceCents).
certifiedPaths: [] is a shortcut for "certify everything in data". Use it only when data itself is curated for that purpose.
Read the active certification key
GET /v1/dpp/certification-key returns the public certification key for your organization, its on-chain status, and the networks it is registered on. Use it to verify a passport's signature client-side, or to surface the key fingerprint in your back-office.
$key = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->get('v1/dpp/certification-key')
->throw()
->json();
// $key === [
// 'id' => '<uuid>',
// 'organizationId' => '<uuid>',
// 'publicKey' => '<hex, no 0x prefix>',
// 'status' => 'active', // active | pending | rotated | revoked
// 'onChainStatus' => 'registered', // registered | pending
// 'registeredNetworks' => ['StarknetMainnet'],
// 'explorerUrl' => 'https://starkscan.co/...',
// 'rotation' => null, // populated while a key rotation is in flight
// 'activatedAt' => '...', 'deactivatedAt' => null,
// ...
// ];
The history and rotate endpoints are also available — rotation is typically triggered from the Keyban Dashboard rather than from a partner integration. Past certificates remain verifiable against rotated keys.
API reference:
GET /v1/dpp/certification-key·GET /v1/dpp/certification-key/history·POST /v1/dpp/certification-key/rotate
Claim and on-chain mint
Publishing a passport anchors its certificate on-chain (signature over certifiedPaths). It does not transfer the underlying ERC-721 token to anyone — at this point the item passport has a deterministic tokenId but its mintedTo field is null. The on-chain mint (ERC-721 transfer to the end-user wallet) only fires when the consumer claims the passport.
The claim endpoint itself (POST /v1/dpp/claim) is consumer-facing and consumed by the Keyban front-end SDK — it is not part of the partner REST API and cannot be called from PHP. The partner's job is to prepare the claim by providing one of two pieces of information:
| Flow | Partner action (PHP) | Consumer action |
|---|---|---|
| Email / phone gating | Set allowedClaimEmail or allowedClaimPhoneNumber on the item passport | Sign in / sign up to a Keyban-powered front-end with the matching email or phone — the matching items are minted to the new account automatically |
| Magic token | Call GET /v1/dpp/passports/{id}/magic-token, deliver the JWT (QR code, packing slip, post-purchase email, …) | Present the JWT to the Keyban consumer SDK, which performs the claim regardless of email/phone |
Both flows converge on the same on-chain mint and end up populating mintedTo with the new owner's account UUID. Read mintedTo (and the public tokenId) to know whether an item has already been claimed.
Prepare
Partner sets allowedClaimEmail/PhoneNumber, or fetches a magic token via GET /v1/dpp/passports/{id}/magic-token.
Claim
Consumer signs in with the matching email/phone, or presents the magic token to the Keyban SDK.
Queue mint
Backend queues a BullMQ mint job. If the consumer account does not exist yet, the job replays once it is created.
Minted
Token minted on-chain. mintedTo is set to the owner's account UUID; transactionHash is recorded.
Set claim restrictions at create or update time
allowedClaimEmail and allowedClaimPhoneNumber are partial fields on item passports — set them as soon as the buyer's contact information is known (typical pattern: at order fulfilment).
Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->asJson()
->patch('v1/dpp/passports/' . rawurlencode($itemId), [
'allowedClaimEmail' => 'jane@example.com',
// or:
// 'allowedClaimPhoneNumber' => '+33612345678', // E.164
])
->throw()
->json();
allowedClaimEmailis a standard email;allowedClaimPhoneNumberis E.164 (+then digits, no spaces).- An empty string is coerced to
nullserver-side (= no restriction). - Available on
granularity = itemonly. - Both fields are also accepted at create time on the
itemvariant ofPOST /v1/dpp/passports.
If the consumer's account already exists at the time you set allowedClaim*, the mint runs immediately. Otherwise it is queued and replayed when the matching account is later created (mintedTo flips from null to the account UUID, transactionHash is recorded by the mint job).
Generate a magic token
When you cannot tie a passport to a stable email or phone (gift cards, anonymous unboxing, scratch-card flows), generate a magic token instead. The token is a JWT signed by the Keyban server-side secret with the passport id in its jti claim — anyone holding it can claim the passport.
API reference:
GET /v1/dpp/passports/{id}/magic-token
$response = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->get('v1/dpp/passports/' . rawurlencode($itemId) . '/magic-token')
->throw()
->json();
// $response === ['magicToken' => 'eyJhbGciOiJI...']
Treat the magic token as a bearer credential. Do not log it, do not store it long-term server-side, and prefer single-use delivery (QR on the physical product, signed-URL email, one-time NFC tag).
:::note Token lifetime
The magic token has no exp claim and no server-side revocation endpoint today — it remains valid as long as the signing secret is unchanged. Plan delivery accordingly (single-use channel, prompt rotation if a token leaks).
:::
Track the claim outcome
There is no PHP-callable endpoint that polls the claim job directly (the job-status SSE is also consumer-facing). To detect that a passport has been claimed, poll GET /v1/dpp/passports/{id} and inspect:
mintedTo—nullwhile unclaimed, then the owner's account UUID.statusstayspublishedeither way;claimis orthogonal to the lifecycle status.
Idempotency on POST /v1/dpp/passports
The API enforces uniqueness on the tuple (application, granularity, modelNumber, batchNumber, itemNumber) — a duplicate create returns 409 Conflict instead of producing a duplicate row.
- Treat
modelNumber/batchNumber/itemNumberas your stable business keys (SKU, lot, serial). Do not generate them randomly. - Do not auto-retry create on
5xx. A5xxmay mean the server accepted the call but the response was lost. List with your business filters and reconcile before retrying. - Treat
409 Conflictas a "this passport already exists" signal — fetch it withGET /v1/dpp/passports?filters=...&pageSize=1and continue.
Retries
Retryable status codes: 408, 429, 500, 502, 503, 504. Honour the Retry-After response header when present.
use Illuminate\Http\Client\RequestException;
Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->retry(
times: 3,
sleepMilliseconds: function (int $attempt, ?Throwable $e) {
if ($e instanceof RequestException) {
$retryAfter = $e->response->header('Retry-After');
if (is_numeric($retryAfter)) {
return ((int) $retryAfter) * 1000;
}
}
return 1000 * (2 ** ($attempt - 1));
},
when: fn (Throwable $e) => $e instanceof RequestException
&& in_array($e->response->status(), [408, 429, 500, 502, 503, 504], true),
)
->timeout(30)->connectTimeout(5)
->get('v1/dpp/passports', $filters);
Errors (RFC 7807)
Errors come back as application/problem+json with type, title, detail, status. Treat detail as server-side context: surface only title (and possibly status) to end users, and avoid forwarding the raw payload to external logs or third parties.
use Illuminate\Http\Client\RequestException;
try {
$response = Http::baseUrl(getenv('KEYBAN_BASE_URL'))
->withHeaders(['X-Api-Key' => getenv('KEYBAN_API_KEY')])
->acceptJson()
->asJson()
->post('v1/dpp/passports', $payload)
->throw();
$passport = $response->json();
} catch (RequestException $e) {
$problem = $e->response->json();
// $problem['type'], $problem['title'], $problem['status'] are safe to expose.
// $problem['detail'] may include server-side context — log it server-side only.
}
Reference
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /v1/dpp/passports | X-Api-Key | Create a draft passport |
PATCH | /v1/dpp/passports/{id} | X-Api-Key | Update a draft (or re-certify a published) passport |
POST | /v1/dpp/passports/publish | X-Api-Key | Publish drafts matching filters (irreversible) |
GET | /v1/dpp/passports/{id} | public | Read a passport |
GET | /v1/dpp/passports | X-Api-Key | List and filter passports |
GET | /v1/dpp/certification-key | X-Api-Key | Read the active certification key |
GET | /v1/dpp/passports/{id}/magic-token | X-Api-Key | Mint a JWT to delegate claim of an item passport |
POST | /v1/imports | X-Api-Key | Bulk import (upsert) passports from a JSON array — async |
GET | /v1/imports/{jobId} | X-Api-Key | Read an import job envelope |
GET | /v1/imports/{jobId}/progress | X-Api-Key | SSE stream of {processed, unprocessed, failed, ignored} |
GET | /v1/imports/{jobId}/report | X-Api-Key | Per-row status + error or persisted passport |