KebecPhoto Lab Pull API v1
Machine-to-machine integration guide for Photolab Yves Thomas — version 1.1 — July 2026
1. Introduction
The Lab Pull API lets your system fetch print batches from KebecPhoto over HTTPS —
the manifest(s) plus every print-ready image file — instead of waiting for our SFTP deposit
into your /incoming folder. The manifest served by this API is the
fully-detailed SoumissionLabo XML (see §7). It
differs from the simplified Timestone CSV we deposit on SFTP: the image files and
their filenames are byte-identical across both channels, but the manifest is not —
SFTP carries the Timestone CSV, this API carries the SoumissionLabo XML.
During the transition period the two channels run in parallel (dual-rail): we keep depositing to your SFTP exactly as today, and the same batches are simultaneously available through this API. Once your integration is validated in production, we will (by mutual agreement) stop the SFTP deposit; the API then becomes the only delivery channel. The switch is instantly reversible on our side.
SoumissionLabo XML — same orders, different envelope. Every file (and manifest) comes
with its size and SHA-256 checksum so you can verify each download.
What the API gives you:
- List batches awaiting pickup (or already confirmed), oldest first.
- Inspect a batch: its manifest(s) and a complete file inventory with checksums.
- Download each manifest (
SoumissionLaboXML) and each image file (JPEG) individually — re-downloadable at will. - Confirm receipt of a batch once you have everything, which removes it from your “to fetch” list.
2. Quick start
# 1. List batches awaiting pickup (oldest first)
curl -H "Authorization: Bearer $KEY" \
"$BASE/batches?status=UPLOADED"
# 2. Get a batch's file inventory
curl -H "Authorization: Bearer $KEY" \
"$BASE/batches/{batchId}"
# 3. Download the manifest(s)
curl -OJ -H "Authorization: Bearer $KEY" \
"$BASE/batches/{batchId}/manifests/{name}"
# 4. Download every file (max 4 in parallel)
curl -OJ -H "Authorization: Bearer $KEY" \
"$BASE/batches/{batchId}/files/{filename}"
# 5. Confirm receipt (idempotent)
curl -X POST -H "Authorization: Bearer $KEY" \
"$BASE/batches/{batchId}/confirm"
3. Environments & base URLs
| Environment | Base URL | API key prefix |
|---|---|---|
| Staging (integration testing) | https://staging.kebecphoto.com/api/lab/v1 | kplab_stg_… |
| Production | https://app.kebecphoto.com/api/lab/v1 | kplab_prod_… |
Keys are environment-bound: a staging key is cryptographically inert against production and vice versa (the server rejects a mismatched environment tag before any lookup). You will receive a staging key first; we move to production once your end-to-end flow is validated against staging test batches.
4. Authentication
Every request carries your API key in the Authorization header:
Authorization: Bearer kplab_prod_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
- Format:
kplab_+ environment tag (prod/stg) +_+ 43 URL-safe base64 characters. Send it exactly as provided. - Storage: treat the key like a password — secrets vault only; never in source control, logs, URLs, or client-side code. We store only a SHA-256 hash (plus the first few characters, for display in our admin); the key cannot be re-displayed. If lost, we mint a new one.
- Rotation: two keys can be active simultaneously. We hand you the new key, you switch at your convenience, we revoke the old one — zero downtime.
- Compromise: contact Francis immediately; revocation takes effect on the very next request.
Authentication failures
Every authentication failure — missing header, malformed key, unknown key, revoked key, expired key — returns the same uniform response (deliberately: no information leaks about key state):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: application/json
{"error":"unauthorized"}
429 with a Retry-After header. (Requests without a syntactically valid key
always get a plain 401.) A correctly configured client
never encounters this; if you do, stop retrying, check the key, and respect
Retry-After.
5. Conventions
- TLS required. HTTPS only; plain HTTP is not served.
- JSON, UTF-8 for all structured responses; raw bytes for manifest/file downloads.
- Timestamps are ISO 8601 UTC with milliseconds, e.g.
2026-06-10T14:05:12.000Z. - Identifiers (batch ids) are opaque strings (25-char
cmk…style). Treat them as opaque; never parse them. - Errors always use the envelope
{"error":"<snake_case_code>"}, sometimes with extra fields (e.g.retry_after,supersededByBatchId). Match on theerrorcode, not on HTTP status alone. - Unknown JSON fields may be added at any time (additive evolution) — your parser must ignore fields it does not recognize.
- Request bodies are optional everywhere.
POST …/confirmworks with an empty body exactly as before; it MAY carry an optional verification report (see 6.5). No other batch endpoint reads a body. - Path parameters need no escaping. Batch ids, manifest names, and filenames are guaranteed to contain only
A–Z a–z 0–9 . _ -— percent-encoding them is a harmless no-op. Use the values exactly as returned by the API. - The inventory of an UPLOADED batch is immutable. Once listed, its
files[]/manifests[]entries, sizes, and checksums never change in place. Any correction on our side happens through a NEW batch (the old one disappearing or going410).
6. Endpoint reference
6.1 List batches
GET/batches
Returns batches visible to your lab, oldest first by creation time
(createdAt ascending, deterministic id tiebreak), with cursor pagination. Note this is
creation order, not availability order — a deferred school batch that becomes available tonight can
appear ahead of batches created after it. Only batches in a deliverable state are ever listed — you will never see internal,
failed, or superseded batches here.
| Query parameter | Values | Default | Description |
|---|---|---|---|
status | UPLOADED · CONFIRMED · all | UPLOADED | UPLOADED = available, not yet confirmed by you. CONFIRMED = already acknowledged. Any other value falls back to the default. |
since | ISO 8601 | — | Only batches whose files became available at/after this instant. This is availability time, not creation time: a school batch created three days ago but uploaded tonight WILL appear. An unparseable value is ignored. |
cursor | batch id | — | Resume after this batch id — only ever send the nextCursor from the immediately preceding response. A malformed value is ignored (you get the first page); an unknown id yields an empty page (hasMore: false) — restart without a cursor on your next poll. |
limit | 1–100 | 20 | Page size (values outside the range are clamped). |
Response 200:
{
"batches": [
{
"id": "cmkabc1230000xyz456def789",
"status": "UPLOADED",
"createdAt": "2026-06-10T14:00:00.000Z",
"submittedAt": "2026-06-10T14:05:12.000Z",
"confirmedAt": null,
"orderCount": 3,
"itemCount": 12,
"isConsolidated": false,
"trigger": "DATED_DEADLINE",
"filesAvailable": true
}
],
"nextCursor": null,
"hasMore": false
}
| Field | Type | Meaning |
|---|---|---|
id | string | Batch identifier — use it in every other endpoint. |
status | string | UPLOADED or CONFIRMED (see lifecycle). |
createdAt | timestamp | When the batch was created on our side (can be days before availability for school-delivery consolidation). |
submittedAt | timestamp · null | When the batch finished uploading / became deliverable. |
confirmedAt | timestamp · null | When YOU confirmed receipt (null until then). |
orderCount / itemCount | number | Orders and print line-items in the batch (matches the manifest's <NombreCommandes>/<NombreArticles>). |
isConsolidated | boolean | Informational: true when the batch was produced by nightly consolidation or manually triggered for immediate processing (cross-check trigger: NIGHTLY_RUN vs MANUAL). Safe to ignore. |
trigger | string · null | What caused submission: DATED_DEADLINE, NIGHTLY_RUN, SAME_DAY_HOME, MANUAL, or null (legacy). |
filesAvailable | boolean | false = nothing was ever published for API download (the batch pre-dates the API) — skip it. Note: in the LIST this flag does not reflect post-retention purge; purge is reported by the DETAIL endpoint (purged: true, whose own filesAvailable then reads false). |
nextCursor / hasMore | string · null / boolean | Pagination: pass nextCursor back as cursor while hasMore is true. |
6.2 Batch detail (file inventory)
GET/batches/{batchId}
All list fields above, plus the complete inventory: manifests and image files, each with size and SHA-256.
{
"id": "cmkabc1230000xyz456def789",
"status": "UPLOADED",
"…": "…(all list fields)…",
"filesAvailable": true,
"purged": false,
"manifests": [
{
"name": "BATCH-2026-06-10-cmkabc1230000xyz456def789.xml",
"groupKey": "batch",
"sizeBytes": 8421,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
],
"files": [
{
"filename": "KP-20260610-A1B2C3_001_YT-4X6-MAT.jpg",
"groupKey": "batch",
"orderRef": "KP-20260610-A1B2C3",
"quantity": 5,
"lineType": "STANDARD",
"side": null,
"sizeBytes": 2048312,
"sha256": "ab12cd…"
},
{
"filename": "KP-20260610-A1B2C3_007_YT-CARTE_CARD.jpg",
"side": null, "…": "…"
},
{
"filename": "KP-20260610-A1B2C3_012_YT-CARTE_FRONT.jpg",
"side": "FRONT", "…": "…"
},
{
"filename": "KP-20260610-A1B2C3_012_YT-CARTE_BACK.jpg",
"side": "BACK", "…": "…"
}
]
}
| Field | Type | Meaning |
|---|---|---|
manifests[].name | string | Manifest filename — pass it verbatim to the manifest download endpoint. One per delivery group (see groupKey). |
files[].filename | string | Exact lab filename — identical to the SFTP deposit and to the manifest's <FichierImage> / <FichierImageVerso> values. Pass it verbatim to the file download endpoint. |
groupKey | string | Delivery group, mirroring the SFTP folder layout: batch = orders grouped to the studio (your batch/ folder); ORDER-… = one direct-to-home shipment per group (your orders/ folder). |
orderRef | string · null | Human order number the file belongs to (also embedded in the filename). |
quantity | number · null | Print quantity for the line (one file may print N copies — matches the manifest's <Quantite>). |
lineType | string · null | STANDARD (single print / sports card) or SHEET (pre-imposed composite sheet, print as-is). |
side | string · null | Sports-card faces only: FRONT / BACK for the legacy recto/verso pair (a front-only card carries FRONT). Null for the merged _CARD file (the filename suffix identifies it) and for everything else. |
sizeBytes / sha256 | number / string | Exact byte size + SHA-256 (hex) of the file — verify after download. |
purged | boolean | True when the files passed retention and were removed (see retention) — inventory metadata remains, downloads 404. |
Special status code — 410 Gone: if a batch you previously saw was
replaced by a consolidated batch before pickup, its detail returns:
{"error":"batch_superseded","supersededByBatchId":"cmk…"}
supersededByBatchId, string or
null) instead — it appears in your normal list. If supersededByBatchId is null, simply
discard and rely on your normal status=UPLOADED polling. If file downloads start
returning 404 mid-drain, re-fetch the batch detail — a 410 there is this case.6.3 Download a manifest
GET/batches/{batchId}/manifests/{name}
Streams the manifest — the fully-detailed SoumissionLabo XML
(see §7 for the schema). UTF-8, opening with an
<?xml version="1.0" encoding="UTF-8"?> declaration. This is the API's manifest
format; the SFTP deposit instead carries the simplified Timestone CSV.
Response headers on success (200):
Content-Type: application/xml
Content-Length: 8421
Content-Disposition: attachment; filename="BATCH-2026-06-10-….xml"
X-Checksum-SHA256: 9f86d081…
Cache-Control: private, no-store
404 {"error":"not_found"} for an unknown name, a purged batch, or a batch that is
not (or no longer) deliverable.
6.4 Download a file
GET/batches/{batchId}/files/{filename}
Streams one print-ready JPEG, byte-identical to the SFTP deposit. Same headers as above with
Content-Type: image/jpeg.
- Concurrency: maximum 16 simultaneous downloads per lab (all of your active keys/devices share the slots). A 17th parallel request gets
429 {"error":"too_many_concurrent_streams"}withRetry-After— wait for a transfer to finish, then retry. - Every transfer has a hard 90-second total time limit (it cuts slow transfers, not just stalled ones). Files are a few MB, so a healthy transfer finishes in seconds — if a download is cut repeatedly, check your effective link speed. Re-downloading is always safe. Set your own client timeout slightly above (e.g. 120 s).
- Verify the SHA-256 against the inventory value; on mismatch (vanishingly unlikely — would indicate transit corruption), re-download.
- A download slot is released the moment your connection closes (including aborts/crashes); a pathological case self-heals within at most 120 s. A crashed drain never locks you out.
- No
HEADorRange/resume support — recovery is always a full re-download (files are small enough that this is the simpler, safer contract). - Every download is re-downloadable at will within the retention window — re-fetching is always safe.
6.5 Confirm receipt
POST/batches/{batchId}/confirm
Call once ALL files of the batch are downloaded and verified. The batch flips to
CONFIRMED (and drops out of your default status=UPLOADED list). Empty
request body.
Response 200:
{
"id": "cmkabc1230000xyz456def789",
"status": "CONFIRMED",
"confirmedAt": "2026-06-10T15:42:08.000Z",
"alreadyConfirmed": false
}
- Idempotent: confirming an already-confirmed batch returns
200with"alreadyConfirmed": true(and the originalconfirmedAt) — never an error. Two concurrent confirms both get200. 404 not_found: unknown batch (or not deliverable to your lab).409 invalid_status: the batch exists but cannot be confirmed (e.g. pre-API batch with no fetched files, or a batch in an internal error state). If persistent, contact Francis.410 batch_superseded: see 6.2 — do not print; usesupersededByBatchId.
Optional verification report (since 2026-06-12). A client that verifies its downloads (such as the KebecPhoto Labo desktop app) may attach a JSON body (≤ 1 KB):
{
"agentVersion": "1.0.0",
"mode": "auto",
"imagesVerified": 135,
"manifestsVerified": 2,
"bytesVerified": 287345234,
"sha256MismatchCount": 0,
"durationMs": 184233
}
- The server validates the report against ITS OWN inventory (per-kind counts + total
bytes). Any mismatch, or
sha256MismatchCount > 0⇒422 verification_failedand the batch is NOT confirmed — re-download, then confirm again. - An accepted report is stored; the response then carries a
reportfield. Re-confirming returns the report ALREADY stored (never overwritten). - Malformed body ⇒
400 invalid_report. An EMPTY body keeps the legacy response shape byte-identical (noreportkey). - The list response now also carries an additive
serverTime(ISO 8601), captured BEFORE the database read — use it (minus the recommended 1-hour margin) as your nextsincewatermark instead of your machine clock. - Manifest downloads now count toward the same concurrent-stream slots and 90 s per-transfer limit as file downloads.
7. Data reference
Filename grammar
{orderRef}_{seq}_{labSku}[_{FRONT|BACK|CARD}].jpg
KP-20260610-A1B2C3 _ 001 _ YT-4X6-MAT .jpg → standard print
KP-20260610-A1B2C3 _ 007 _ YT-CARTE _ CARD .jpg → MERGED double-sided card (default)
KP-20260610-A1B2C3 _ 012 _ YT-CARTE _ FRONT .jpg → card, recto (legacy pair / front-only)
KP-20260610-A1B2C3 _ 012 _ YT-CARTE _ BACK .jpg → card, verso (legacy pair)
orderRef— human order number (also in the inventory'sorderReffield).seq— line index within the order, zero-padded, 1-based (matches<NumeroSequentiel>).labSku— YOUR product code, as configured per product (the manifest's<CodeProduit>). A product code NEVER ends in_FRONT,_BACKor_CARD— those terminal tokens are reserved for the file role below.
Sports cards (recto/verso)
Default — merged _CARD file: a double-sided card now deposits ONE
file: front and back side-by-side on a single double-width image (e.g. a 2.5×3.5 in card →
one 5×3.5 in-class file). The density (DPI) tag written into the JPEG is the physical truth
of the format — the file IS the final sheet, pre-rendered at print dimensions: print verbatim, no
cropping or resizing, then cut at the centre line. In the
SoumissionLabo XML the merged line is ONE <Article> with a single
<FichierImage> (never a <FichierImageVerso>) and
no size tags (<TailleCm>/<TaillePo> omitted —
the single-face size would be wrong for the double-width sheet). In the API inventory: a single
entry, side: null, identified by the _CARD suffix.
Legacy — _FRONT/_BACK pair: card lines predating the
migration still deposit TWO files forming ONE <Article> carrying both
<FichierImage> (recto) and <FichierImageVerso> (verso), so one
billable card remains a single article. In the API inventory they appear as two entries sharing the
same orderRef, distinguished by side: "FRONT" / "BACK". Both
are pre-rendered at print dimensions — print verbatim, no cropping. (A front-only card stays a
single _FRONT file.)
Imposed sheets
lineType: "SHEET" entries are pre-imposed composite JPEGs (300 DPI, ready to
print as-is); the manifest article carries <Recadrage applique="non" /> and no
size elements — exactly as described in our June 2 exchange about feuilles imposées.
Delivery groups (groupKey)
groupKey | SFTP equivalent | Meaning |
|---|---|---|
batch | batch/ folder | School-delivery orders grouped for the studio (one manifest for the group; studio address in <Livraison type="ecole">). |
ORDER-{date}-{ref} | orders/ folder | One home-delivery order shipped directly to the parent (its own manifest; address in <Livraison type="expeditionDirecte">). |
A mixed batch contains the batch group AND one ORDER-… group per
home-delivery order — download every manifest listed in manifests[].
The manifest (SoumissionLabo XML)
The API manifest is the fully-detailed SoumissionLabo XML — richer than the simplified
Timestone CSV we deposit on SFTP; the two are not interchangeable. Root element
<SoumissionLabo>; one <Article> per print line
(a merged _CARD card is one article with a single <FichierImage> and
no size tags; a legacy pair is one article with both <FichierImage> and
<FichierImageVerso>). Family orders mix children within one
<Commande>, each article carrying its own student. UTF-8, with an XML
declaration.
| Element | Where | Content |
|---|---|---|
<Lot> | root | Batch header: <NumeroLot>, <DateCreation>, <NombreCommandes>, <NombreArticles>, <NombreTirages>. |
<Studio> | root | Photographer: <Nom>, <CodeStudio>, <CompteLaboYT> (your lab account number for this studio). |
<Commande> | under <Commandes> | One per order: <NumeroCommande> + the blocks below. |
<Client> | order | Customer <Nom>, <Courriel>, <Telephone>. |
<Livraison type="…"> | order | ecole (school/studio grouping — the batch group), expeditionDirecte (direct-to-parent — an ORDER-… group), or numerique (digital, no address). Address children: <Ligne1>, <Ville>, <Province>, <CodePostal>, <Pays>. |
<Article> | under <Articles> | One per print line. Key children: <CodeProduit> (YOUR product code), <Quantite>, <FichierImage> (+ <FichierImageVerso> for LEGACY card-pair versos only — never on a merged _CARD line), <Recadrage applique="oui|non"> with <X>/<Y>/<Largeur>/<Hauteur> when cropped. <TailleCm>/<TaillePo> are omitted on merged _CARD lines (and on imposed sheets). |
| optional article tags | article | <Decor> (background design name), <GroupeSource> (group-photo source group), <CommentairesCommande> (parent order comment, when enabled). |
Which manifest you get follows the delivery groups (groupKey): the batch
group's manifest carries <Livraison type="ecole">; each ORDER-…
group's manifest carries <Livraison type="expeditionDirecte">. (The simplified
Timestone CSV is the format we deposit on your SFTP; this API always serves the detailed
SoumissionLabo XML. We would notify you before changing either.)
8. Recommended integration flow
Poll every 5 minutes; persist two things on your side: the set of batch ids you have fully processed, and the timestamp of your last successful poll.
every 5 minutes:
since = last_successful_poll − 1 hour # overlap on purpose; dedupe by batch id
page = GET /batches?status=UPLOADED&since={since}
loop:
for batch in page.batches:
if batch.id in processed or not batch.filesAvailable: continue
detail = GET /batches/{batch.id} # 410 → mark void, print superseding batch instead
for m in detail.manifests: download + verify sha256
for f in detail.files: download + verify sha256 # ≤ 4 in parallel
hand off to production pipeline
POST /batches/{batch.id}/confirm # idempotent — safe to repeat after a crash
mark batch.id processed
if not page.hasMore: break
page = GET /batches?…&cursor={page.nextCursor}
last_successful_poll = now
- Crash recovery: everything is re-fetchable and confirm is idempotent — after a crash, simply reprocess the batch from the top.
- Dedupe by batch id, not by time: the 1-hour overlap guarantees no gaps; the id set guarantees no double-prints.
- Backoff: on
429honourRetry-After; on5xxretry with exponential backoff (5 s, 10 s, 20 s… cap 5 min). Never tight-loop. - Do not parse filenames for routing when the inventory metadata (
groupKey,orderRef,side,lineType) already answers the question. - Safety net: once a day (or on restart), run a full
GET /batches?status=UPLOADEDwithoutsinceand reconcile against your processed-id set —sinceis an optimization, never the source of truth. (A batch you never confirm stays listed there indefinitely.) - Clock authority: derive your
sincewatermark from OUR timestamps (e.g. the newestsubmittedAtyou have seen, minus the 1-hour overlap), not from your local clock — this makes clock skew irrelevant. - Terminal file failure: if a file listed in the inventory 404s, or its SHA-256 still mismatches after a few re-downloads — both should never happen — do not confirm the batch; it stays
UPLOADED(nothing times out) and visible in your safety-net list. Contact Francis.
9. Error catalog
| HTTP | error code | Meaning | What to do |
|---|---|---|---|
| 401 | unauthorized | Missing/invalid/revoked/expired key (uniform — no detail by design). | Verify the key and environment; contact Francis if it persists. |
| 404 | not_found | Unknown batch/manifest/file, purged files, or a resource outside your lab's scope. | Skip; re-list. For a purged batch, nothing to fetch. |
| 405 | method_not_allowed | Wrong HTTP method (e.g. GET on confirm). | Fix the request. |
| 409 | invalid_status | Batch exists but cannot be confirmed right now. | Re-check the batch via detail; contact Francis if persistent. |
| 410 | batch_superseded | Do not print. Batch replaced by supersededByBatchId. | Void locally; process the replacing batch. |
| 413 | payload_too_large | A request body exceeded 1 KB (the confirm report cap). | Keep the optional report under 1 KB; send empty bodies elsewhere. |
| 429 | rate_limited | Endpoint rate limit exceeded (or auth-failure cooldown). | Wait Retry-After seconds (also in the JSON as retry_after). |
| 429 | too_many_concurrent_streams | More than 16 simultaneous file downloads. | Wait for a transfer to complete; retry after Retry-After. |
| 503 | too_many_concurrent_streams | Transient server-side inability to track download slots. | Retry after Retry-After (seconds). |
| 5xx | — | Transient server error. | Exponential backoff and retry; alert us if sustained. |
10. Rate limits & concurrency
Limits are per lab (all of your active keys share the same buckets), per minute (fixed window).
The poll/confirm limits sit far above the documented cadence. The file bucket (600/min) paces
large batches — while draining a big batch you should not normally see a 429 rate_limited;
if you do, honour Retry-After and continue.
| Endpoint | Limit |
|---|---|
GET /batches (list) | 120 / min |
GET /batches/{id} (detail) | 240 / min |
GET …/manifests/{name} | 120 / min |
GET …/files/{filename} | 600 / min + max 16 concurrent transfers |
POST …/confirm | 60 / min |
Batch splitting uses a limit of 500 image lines, with manifests and some additional files added separately; download duration depends on data volume and transfer speed.
An additional per-IP safety cap (sized well above the sum of the buckets) protects the API pre-authentication; it is unreachable at the documented limits but applies per source IP — run your poller as a single coordinated client rather than many parallel processes behind one egress IP.
11. Data retention & batch lifecycle
- A batch appears with
status: "UPLOADED"the moment its artifact set is complete and deliverable. - Your
POST /confirmflips it toCONFIRMED. - Files remain downloadable for at least 60 days after your confirmation; after that they may be purged — the batch's detail then reports
filesAvailable: false, purged: true(the list'sfilesAvailabledoes not reflect purge) and downloads return 404. Inventory metadata (filenames, sizes, checksums) is kept. - Batches you never confirm stay available — but please confirm: it is our delivery acknowledgement.
- You will never see internal states (created/packaging/failed). The only post-listing state change to handle is
410 batch_superseded(a school batch consolidated before pickup), which carries the replacing batch id.
12. Versioning & support
- The contract is versioned in the path (
/api/lab/v1). Within v1, changes are additive only (new optional fields, new endpoints); breaking changes would ship as/v2with an overlap period. - This page is the authoritative English reference and is updated in lockstep with the API.
- Support: Francis Cossette (KebecPhoto) — your existing email thread. For urgent issues (key compromise, blocked production), call.