# Provkeep API — integration reference For a store that is not WooCommerce, or is WooCommerce with a developer who would rather write twenty lines than install a plugin. **Your code is yours.** Nothing here runs inside your application, nothing here asks you to restructure anything, and there is no package to install. You make HTTPS requests when your own code already has the data to hand. **Who this is written for.** A developer implements it. But a shop owner with one developer-day of budget has to be able to hand this over and get an integration that either works or says loudly that it does not. So every field below states what happens if you leave it out, and the failure modes are written down rather than left to be discovered. --- ## The six things this document promises 1. **Every required field is something your store already has.** Nothing on the required list asks you to build a new capability. Where a field WOULD require that, it is optional and says so. 2. **Every optional field degrades honestly.** Omit it and your documents say *not reported*, never *not captured* and never a guess. The table in §6 states the exact sentence each absence produces. 3. **Ordering is yours to choose.** Consent can arrive before, with, or after the order. §5 says which sequences are supported and what happens if you pick one we did not anticipate. 4. **A retry is safe and §4 says exactly what it does.** 5. **Bad input fails loudly, and where it cannot, §7 says so.** One case degrades quietly rather than erroring, and it is named. 6. **§9 is a complete request you can paste and run**, not fragments. --- ## 1. Getting a key Settings → API keys in your Provkeep dashboard. The key is shown once. **Issue one key per system.** A key can be revoked without touching the others, which matters when a contractor's access ends or a server is decommissioned. ``` Authorization: Bearer coas_live_... X-COA-Storefront: Idempotency-Key: Content-Type: application/json ``` All four are required on `POST /api/v1/orders`. A missing `Idempotency-Key` or `X-COA-Storefront` is a `400` with a message naming the header. --- ## 2. The one call that matters `POST /api/v1/orders`, once per order, when the order is created. That single call records the order, its lines, which lot filled each line, the customer's identity fingerprints, and the consent record — everything the underwriting bundle and the dispute packets are built from. **If you implement nothing else, implement this.** Everything else in this document is optional and additive. --- ## 3. Required fields, and why each is something you already have | field | what it is | you already have it because | |---|---|---| | `external_order_id` | your order id, as a string | it is your primary key | | `amount_minor` | total in the smallest currency unit, integer | you charged it | | `currency` | ISO 4217, three letters | you charged it in something | | `placed_at` | ISO 8601 **with an offset** | your order row has a created timestamp | | `lines[].line_index` | 0-based position in the order | it is the array index | | `lines[].quantity` | integer, at least 1 | it is on the line | That is the whole required set. **Everything else is optional**, and §6 says what each absence costs. `gateway` defaults to `"unknown"` and `currency` to `"USD"`, but send both. --- ## 4. Idempotency, and exactly what a retry does `Idempotency-Key` is required. Use a value that is **stable for the event**, not random per attempt — the whole point is that a retry carries the same key. A workable scheme: `order--`, e.g. `order-1042-created`, `order-1042-shipped`. **What happens, precisely:** | you send | result | |---|---| | a key that has been seen before | **No duplicate is created.** The original order is looked up and its current summary is returned, `201`. Safe to retry as often as you like | | a **new** key for an order id that already exists | The existing order is **updated** — lifecycle status is applied if it changed. No second order. This is how you send a status change later | | a new key, a new order id | The order is created | | a key seen before, but the original order cannot be found | `409 conflict`. This means the first attempt archived the request and then failed; do not loop on it, look at it | **So: retry freely.** The failure mode this design refuses is the one where a network timeout produces two orders and your underwriting bundle double-counts. --- ## 5. Ordering — consent before, with, or after the order **Supported: consent arrives WITH the order, in the same request.** That is the recommended sequence and it is the only one that needs a single call. **Supported: the order first, consent details later**, by sending `POST /api/v1/orders` again with the same `external_order_id`, a new `Idempotency-Key`, and the consent object filled in. The existing order is found and updated. **Not supported: consent before the order exists.** There is nothing to attach it to. If your checkout collects agreement before it creates an order row, hold it in your own session and send both together. **Why the order of lines does not matter:** `line_index` is what identifies a line, not its position in the array. **If you get it wrong**, the failure is visible rather than silent: an order synced without consent shows on your dashboard as having no consent record, and your underwriting bundle counts it. Sending the consent afterwards fixes it. **Consent cannot be backdated to an order that was placed without it** — the record is written when it is received, and no surface will claim otherwise. --- ## 6. Every optional field, and what its absence produces **This is the section to read before deciding what to skip.** Absence is never treated as a negative finding. "We were never told" and "there was nothing to tell" are different states and produce different sentences. ### Identity — used for 10.4 fraud disputes | field | omit it and | |---|---| | `customer_email_sha256` | no email comparison runs. The fraud packet reports the element as **not captured by this store**, never as a mismatch | | `shipping_address_hash` | same — reported as not captured. (Matched addresses are shown but never *counted*: the criteria require clear text and this is a hash) | | `customer_ref` | the account-id element is unavailable. It is one of only three countable elements, so this materially weakens a 10.4 | | `consent.ip_address` | **the most costly omission.** IP is one of the two elements a qualifying case must include one of. Without it, and without a device fingerprint, no 10.4 case can qualify at all | | `consent.device_fingerprint` | the device element is unavailable. See §8 — this is the field that would require you to build something | ### The lot — used for 13.3 / 13.5 quality disputes | field | omit it and | |---|---| | `lines[].raw_sku` | the line cannot be tied to a product, so no lot and no certificate. The line shows as unresolved | | `lines[].batch_id` | **we fall back to the lot your store had active for that product**, recorded at confidence `0.75`. Every surface then states *"No lot selection arrived with the order, so the lot recorded as active at that time was used."* If no active lot exists, the line is unresolved | | `lines[].resolution_method` / `confidence` | defaults applied. See §8 for why sending `0.9` you did not earn is the one thing not to do | **`resolution_method` accepts exactly these four values:** | value | what it means | the confidence that goes with it | |---|---|---| | `explicit_metadata` | you assigned this lot to this order | `1.00` | | `active_batch` | you asked which lot was active and are reporting the answer | `0.90` | | `manual` | a person chose the lot | any | | `unresolved` | you could determine no lot for the line | `0` | > **`fifo` was REMOVED on 2026-10-08 and is now rejected.** It had been accepted > by this endpoint and was never produced by anything: no code path on either side > wrote it, zero rows carried it across every store, and its only effect was that > a value you sent would be stored and then described by a rule written for a > different case. > > **If you were sending `fifo`, send `manual` instead** — a lot chosen by a person > — or `explicit_metadata` if your system holds the lot on the order. A request > carrying `fifo` now returns `400` rather than being accepted and misdescribed. > > Nothing else changed: the other four values, their meanings and their confidence > pairings are unchanged. ### Consent | field | omit it and | |---|---| | the whole `consent` object | no consent record. Your documents say so plainly and make no claim of acceptance | | `consent.consent_text_hash` | **the object is rejected** — it is required within `consent`. A consent record that does not fix the wording accepted is not a consent record | | `consent.min_age` / `age_confirmed` | the age sentence is omitted from documents. Nothing claims an age was declared | | `consent.occurred_at` | the acceptance timestamp is absent from the packet | ### Everything else | field | omit it and | |---|---| | `payment_result.avs_result_code` | no AVS evidence. This is worth more than it looks — see §8 | | `gate.applied` / `gate.coverage` | we cannot say **why** an order has no consent record, so a processor reads the absence without explanation | | `consent_source` | see §8 if your own checkout collects agreement | | `lifecycle_status` | the order's commercial state is unknown to us; resolution is unaffected | | `shipments` | we hold no despatch record for the order. Documents say **nothing** about shipping rather than saying it did not ship — see §13. You can send it later instead, and usually will | --- ## 7. What happens when the input is wrong | what you send | what happens | |---|---| | malformed JSON | **`400`**, `"body must be valid JSON"` | | a hash that is not 64 characters | **`400`** with the failing field named. `customer_email_sha256` and `shipping_address_hash` are length-checked exactly | | a bad `placed_at` | **`400`**. It must be ISO 8601 **with an offset** — `2026-09-28T10:14:07Z` is fine, `2026-09-28 10:14:07` is not | | `consent` without `consent_text_hash` | **`400`** | | an `enforcement_mode` or `consent_source` outside the allowed values | **`400`**. Unknown values are refused rather than stored | | a bad or revoked API key | **`401`** | | a missing `Idempotency-Key` or `X-COA-Storefront` | **`400`** naming the header | | **an unknown SKU** | **accepted, and flagged.** The order is recorded and the line shows as not tied to a product. Your dashboard lists it under catalog gaps and your bundle names it. This is deliberate: refusing the order would lose the record entirely | | **a `batch_id` that does not exist** | **accepted, the id discarded, and NAMED IN `warnings`.** The line falls back to your active lot at `0.75` or becomes unresolved | | **a `batch_id` belonging to a different product than the line's SKU** | **accepted, the id REFUSED, and named in `warnings`.** We will not bind it: putting one product's laboratory report against another product's line is worse than leaving the line unresolved, and nothing downstream would notice. The line falls back to that product's own active lot at `0.75`, or becomes unresolved | | a shipment for an order we have never seen | **`404`**, telling you to sync the order first. A shipment attaches to an order that already exists — see §13 | | the same tracking number twice | **`201`**, nothing written the second time, and the number is listed under `already_recorded`. See §13 | ### The check to write The success response is: ```json { "id": "…", "status": "partial", "lines_total": 2, "lines_matched": 1, "lines": [ { "line_index": 0, "raw_sku": "SEMA-5MG", "bound": true, "resolution_method": "explicit_metadata", "confidence": "1.000" }, { "line_index": 1, "raw_sku": "TIRZ-10MG", "bound": false, "resolution_method": "unresolved", "confidence": null } ], "warnings": [ { "line_index": 1, "note": "batch_id belongs to a different product than this line's SKU, so it was not used" } ] } ``` **Log `warnings` if it is present, and assert `lines_matched === lines_total`.** Between them those two catch every case above: `warnings` tells you we overrode something you sent, and the counts tell you a line has no lot for any reason. **`confidence` is a string, not a number**, deliberately — `0.750` and `0.900` are the whole distinction between our inference and your attestation, and a float would let it round. **`lines` is present on a retry too**, read back from stored state, so an idempotent replay reports the same outcomes rather than a thinner response that reads as "nothing to report". **`warnings` is not**: a replay describes a stored order rather than judging a submission, so inventing warnings for it would be asserting something about a payload we are not looking at. `status` is the resolution summary: `matched`, `partial`, `unmatched` or `received`. --- ## 8. Four things worth knowing before you start **1. `batch_id` at `0.9` is a claim, not a formality.** Call `GET /api/v1/batches/active?sku=YOUR-SKU` at checkout, send the `batch_id` you get back with `resolution_method: "active_batch"` and `confidence: 0.9`, and the record says *the store attested to which lot shipped*. Omit it and we infer the lot ourselves at `0.75`, deliberately below the confidence floor, and your documents say it was inferred. **Do not send `0.9` for a lot you did not actually look up** — those two numbers are the only thing separating an attestation from a guess, and a processor who discovers the difference discounts the whole packet. **2. The device fingerprint is the one field that asks you to build something**, so it is optional and flagged rather than listed as a requirement. There is no standard for it. If you want it: hash a stable set of browser properties with a salt you generate once and keep, send the result plus a name for your scheme in `device_algo`. If you would rather not, omit both — the packet reports the element as not captured, which is honest, and everything else still works. **3. AVS is worth more than it looks.** If your gateway returns an address verification result, send it raw in `payment_result.avs_result_code` with a `avs_source` naming the gateway. Do not normalise it. It costs you one field. It is one half of an evidence route for **fraud** disputes — where the cardholder says they did not make the purchase — for a card-absent order whose merchandise was delivered. (Corrected 2026-09-30: this said *non-receipt* disputes. The rule is in Visa's *Allowable Compelling Evidence* table, which applies to conditions 10.1, 10.3 and 10.4, all fraud. Non-receipt is 13.1 and is a different requirement we do not answer — see point 5.) **4. If your own checkout already collects customer agreement**, send `"consent_source": "record_only"` and omit the consent object. Your documents will then say that your checkout collects the agreement and that Provkeep makes no claim about it either way — rather than reporting a missing consent record, which would be false. **Do not send a consent object built from your own checkbox**: we would be attesting to something we did not observe. --- ## 9. A complete request Paste this, replace the values, and it produces a correct record end to end. ```bash curl -sS -X POST https://provkeep.com/api/v1/orders \ -H "Authorization: Bearer $PROVKEEP_KEY" \ -H "X-COA-Storefront: $PROVKEEP_STOREFRONT" \ -H "Idempotency-Key: order-1042-created" \ -H "Content-Type: application/json" \ -d '{ "gateway": "stripe", "external_order_id": "1042", "status": "processing", "lifecycle_status": "processing", "amount_minor": 12900, "currency": "USD", "placed_at": "2026-09-28T10:14:07Z", "customer_ref": "cus_8814", "customer_email_sha256": "b29f68227690dccb16d3e950f33fcd930aa704be4d7a4352121d84fd20d48020", "shipping_address_hash": "0c449c79669779bbd0545e5f33f9f5d31e9c396ff208e7e0c8604ae13dcbdb49", "lines": [ { "line_index": 0, "raw_sku": "SEMA-5MG", "raw_title": "Semaglutide 5mg", "quantity": 2, "amount_minor": 12900, "batch_id": "PASTE-A-REAL-BATCH-ID", "resolution_method": "active_batch", "confidence": 0.9 } ], "gate": { "applied": true, "coverage": "checkout_form" }, "consent": { "consented": true, "consent_text_hash": "e57e132e0b789195f94096cb7230ca3f2c66118da7f0bfc9af8c77ab46312821", "consent_version": "3", "min_age": 21, "age_confirmed": true, "ip_address": "203.0.113.7", "occurred_at": "2026-09-28T10:14:05Z" }, "payment_result": { "avs_result_code": "Y", "avs_source": "stripe" } }' ``` Expect `201` and `{"id":…,"status":"matched","lines_total":1,"lines_matched":1}`. If `lines_matched` is `0`, your `batch_id` did not resolve — see §7. --- ## 10. The hashing, and how to check you got it right Email and address are hashed **in your store**, before anything is sent. We never receive the plain values and do not want them: a breach must not expose a list of people who bought research peptides. | field | recipe | |---|---| | `customer_email_sha256` | `sha256(lowercase(trim(email)))` | | `shipping_address_hash` | `sha256(lowercase(strip_non_alphanumeric(address_1 + postcode + country)))` — **those three fields only**, joined with no separator | | `consent_text_hash` | `sha256(exact text shown)` — raw bytes, no trimming, no normalisation | | `device_fingerprint` | `sha256(algo + "\|" + signals + "\|" + your_salt)` | ### Why this matters more than it looks **If you hash the wrong fields, nothing breaks and nothing tells you.** Your hashes will match each other, identity matching will work, your tests will pass. It fails at a boundary: if this store ever also runs our WooCommerce plugin, or if you migrate between them, you end up with two hash families for one customer. Fraud disputes are argued on **prior** orders — so the failure lands on exactly the orders that matter, appears only inside a dispute, and looks like *"this cardholder has no history"* rather than like a bug. ### Check your implementation against these Every value below is produced by the same code that runs in the plugin, and a test in this repository fails if they ever diverge. **`customer_email_sha256`** — all three must produce the same hash: | input | expected | |---|---| | `Casey@Example.com` | `b29f68227690dccb16d3e950f33fcd930aa704be4d7a4352121d84fd20d48020` | | `··casey@example.com··` (surrounding spaces) | `b29f68227690dccb16d3e950f33fcd930aa704be4d7a4352121d84fd20d48020` | | `casey@example.com` | `b29f68227690dccb16d3e950f33fcd930aa704be4d7a4352121d84fd20d48020` | **`shipping_address_hash`** — `address_1`, `postcode`, `country`. All three must agree: | address_1 | postcode | country | expected | |---|---|---|---| | `12 Boundary Road` | `SW1A 1AA` | `GB` | `0c449c79669779bbd0545e5f33f9f5d31e9c396ff208e7e0c8604ae13dcbdb49` | | `12 boundary road` | `sw1a1aa` | `gb` | `0c449c79669779bbd0545e5f33f9f5d31e9c396ff208e7e0c8604ae13dcbdb49` | | `12, Boundary Road.` | `SW1A-1AA` | `GB` | `0c449c79669779bbd0545e5f33f9f5d31e9c396ff208e7e0c8604ae13dcbdb49` | **The trap.** Including the city — the natural reading of "hash the shipping address" — gives a different hash, consistently, so nothing will warn you: | what you hashed | result | |---|---| | `12 Boundary Road` + `London` + `SW1A 1AA` + `GB` | `c4983e4e23c25b483945c19ad3a96d8e8b965fc89bfa2680b3eb89b05c5bdfca` | If your code returns that second value, it is internally consistent and wrong. **`consent_text_hash`** — the exact bytes shown to the customer. For: ``` I confirm these products are for laboratory research use only, are not for human or veterinary consumption, and that I am at least 21 years of age. ``` | input | expected | |---|---| | the sentence above, verbatim | `e57e132e0b789195f94096cb7230ca3f2c66118da7f0bfc9af8c77ab46312821` | | the same, plus a trailing newline | `1393984ce3e300d6140ccee7049e7ffecdfe96ef34253bc2035e71268399abdf` | The second row is here because a template ending in a newline is the most likely way to get this wrong, and the two hashes share no prefix to warn you. **`device_fingerprint`** — the salt is yours, generated once and kept, so fingerprints are comparable only within your own store: | algo | signals | salt | expected | |---|---|---|---| | `ua-lang-tz-v1` | `Mozilla/5.0\|en-GB\|Europe/London` | `store-salt-EXAMPLE` | `a2e11f541754dd4d00c72cc323581a3d078ff1a4f1d5c30466ff0d1b8e06b267` | --- ## 11. What you actually have to write, and how long it takes **Required, for the one call in §2:** | piece | what it is | |---|---| | two hash helpers | email and address, ~5 lines each, checked against §10 | | one payload builder | map your order row and lines to the shape in §9 | | one POST with four headers | plus a retry, since the endpoint is idempotent | | one assertion | `lines_matched === lines_total`, and log it if not | **Honestly: half a day** for a developer who knows the codebase they are working in, including reading this document and checking the hash vectors. Most of it is the payload builder, and most of that is deciding where in your checkout to call it from. **Optional, and each is small:** | piece | cost | |---|---| | lot binding at `0.9` | one extra GET at checkout, ~an hour | | AVS passthrough | one field, minutes, if your gateway exposes it | | device fingerprint | **half a day on its own** — there is no standard, and you are designing a scheme (§8) | | shipping/fulfilment | one call when you buy a label, ~an hour (§13) | | recording and activating lots | two calls per lot (§12a) | **What you do not have to write:** no webhook receiver, no polling, no background worker, no SDK, no schema migration of your own. There is nothing to keep in sync and nothing of ours running in your process. --- ## 12. The other endpoints **Orders and catalog** | endpoint | for | |---|---| | `POST /api/v1/orders/{your_order_id}/shipments` | report a tracking number after the order — see §13 | | `POST /api/v1/products` | push your catalog, so the bundle knows every SKU you sell | | `POST /api/v1/storefront` | report your consent configuration with no order attached | **Lots** | endpoint | for | |---|---| | `POST /api/v1/batches` | record a lot — see §12a | | `POST /api/v1/batches/{id}/activate` | make it the lot currently shipping | | `POST /api/v1/batches/{id}/supersede` | retire a lot permanently | | `GET /api/v1/batches/active?sku=` | the lot currently shipping for a SKU — call at checkout to earn the `0.9` binding | | `GET /api/v1/batches/{id}` | one lot and its published certificate | **Certificates** | endpoint | for | |---|---| | `POST /api/v1/coa/upload` | upload a laboratory report | | `POST /api/v1/coa/{documentId}/extract` | read the values off it | | `POST /api/v1/coa/records/{recordId}/public-link` | issue the public certificate URL | --- ## 12a. Making the chain — lots and certificates **Read this before you wire up §2.** `GET /api/v1/batches/active?sku=` is how you learn the `batch_id` that earns the `0.9` binding, and it returns nothing until a lot has been **recorded**, **activated**, and carries a **published certificate**. All three used to be dashboard-only, which meant an API-only store could push its catalog and its orders and never make the chain at all — every line falling to the `0.75` fallback, every packet reading *inferred*. These endpoints exist so that is no longer true. ### Record a lot ``` POST /api/v1/batches { "sku": "PK-AOD9604-2", "lot_number": "PL2610-0004" } ``` Exactly one of `sku` or `product_id` — sending both, or neither, is a `400`. The lot is created `pending`: recorded, not yet claimed to be shipping. A lot recorded this way is marked `origin=api`, a third origin rather than being folded in with lots entered at the Products screen. A machine holding a key is not a person reading a certificate, and collapsing the two would record a human act that did not happen. ### Activate it ``` POST /api/v1/batches/{id}/activate ``` This is the claim that this lot is the one currently shipping, and every order binding afterwards rests on it — which is why it is a separate call and not a field on the create. One lot per product is active at a time, so activating one stands the previous one down. The response names it: ```json { "batch": { "id": "…", "status": "active" }, "deactivated_batch_id": "…" } ``` **`deactivated_batch_id` is reported rather than done silently**, because a caller automating this needs to know that activating lot B retired lot A. Treat a non-null value as a thing that happened, not as noise. ### Retire it ``` POST /api/v1/batches/{id}/supersede ``` **`superseded` is terminal.** It is refused twice — by the endpoint and by a database trigger — so there is no revival path, for you or for us. Use it when a lot is finished as a record; it is not a way to say stock ran out. ### The certificate ``` POST /api/v1/coa/upload multipart/form-data, field name: file POST /api/v1/coa/{documentId}/extract ``` Upload stores the PDF and returns `201`, or `200` with `"deduped": true` if you have sent that exact file before. Extract runs the laboratory report through the model and writes what it read. **Then it stops, and this is deliberate.** Extraction writes to a separate table that no customer-facing surface ever reads. Publishing is a human decision taken in the dashboard, one certificate at a time, and **there is no endpoint for it and there will not be one.** A purity figure goes on a live storefront under your name in a category where the regulator writes letters about product claims; a model misreading `9.2%` as `99.2%` becomes a false claim you made. A person confirms the values match the document before anything is published. Lots are rare — roughly twenty a month for a store this serves — so this is one click per lot, not a workflow. Once a record is published you can issue its public URL over the API: ``` POST /api/v1/coa/records/{recordId}/public-link → 201 { "public_url": "https://provkeep.com/coa/…" } ``` After that, `GET /api/v1/batches/active?sku=` returns the lot with `coa.public_url` on it, and §2's `0.9` binding is available to you. ### What stays in the dashboard Stated plainly, because a reference that is silent about its own edges is worse than one that is short: | action | where | why | |---|---|---| | verifying and publishing a certificate | dashboard | above — the one deliberate exclusion | | revoking a public link | dashboard | a new capability on neither path; it needs a spec for both before either gets it | | correcting a lot binding after the fact | dashboard | — | | reading an order back | dashboard | — | | generating the five documents | dashboard | unsettled by design, not by effort: a key authenticates no person, and the column recording who produced a document must not name a machine | The last four are gaps, not decisions. If one of them blocks you, say so — knowing which one an actual integration hits is worth more to us than our guess about it. --- --- ## 13. Despatch — the tracking number, and when to send it **Send this if you can.** It is one extra call at the moment you buy a label, and it is the only thing that lets your documents say a parcel went out. Without it they are silent on shipping, which is the right thing for them to be — but silence wins no argument with a customer claiming nothing arrived. ### Two ways in, and the second is the normal one **A field on the order**, for when you already have the number: ```json { "shipments": [ { "tracking_number": "9400111899223197428490", "carrier": "usps", "shipped_at": "2026-09-14", "source": "custom_store", "service_level": "signature_required" } ] } ``` **Or its own call, afterwards** — which is what most stores need, because a label is usually bought hours or days after the order: ``` POST /api/v1/orders/{your_order_id}/shipments Authorization: Bearer coas_live_… X-COA-Storefront: {your storefront id} { "shipments": [ { "tracking_number": "9400111899223197428490", "carrier": "usps" } ] } ``` **The id in the path is your own order id** — the same string you sent as `external_order_id`. You do not have to store anything of ours to use this. **No `Idempotency-Key` header is needed here**, unlike the order sync. Sending the same parcel twice writes nothing the second time and the response tells you which ones were already there, so there is no duplicate to protect against: ```json { "order_id": "…", "recorded": 1, "already_recorded": ["9400111899223197428490"], "shipments": [ … every parcel we now hold for this order … ] } ``` `shipments` in that response is read back from what is stored, not echoed from what you sent, so a retry shows you the real state. ### The fields | field | required | what it is | |---|---|---| | `tracking_number` | **yes** | exactly as the carrier gave it to you. Not normalised, not reformatted | | `carrier` | no | exactly as you hold it. `usps`, `USPS`, `Royal Mail Tracked 48` — all fine. We do not translate it into a list of our own | | `shipped_at` | no — **but send it** | the date you despatched, as you recorded it. **Worth more than it looks, for the same reason AVS is:** for a *"I did not make this purchase"* dispute the card network asks for the date the goods were provided, and this is the only date that answers it. Leave it out and your fraud packet says the date was not reported — which is honest, and weaker than a date | | `source` | no | where the value came from in your system. Use `custom_store` unless you have something more specific. It exists so that a value read months later can be interpreted without guessing | | `service_level` | no | **send this if you bought signature confirmation.** It is the single strongest piece of evidence when someone says a parcel never arrived, and we will never assume it — a blank here is read as "unknown", never as "no signature" | ### What we do not accept, and will not **There is no delivery status and no `delivered_at`.** Nothing in this system asks a carrier anything, so nothing here can say a parcel arrived. Your documents say *this store recorded despatch of one parcel* — a record you made — and state explicitly that it is not a confirmation from the carrier. A field we could only ever leave empty would invite a reader to take it for "not delivered", which is a different and much worse claim than "we never asked". ### What this record is, and what it is not — read this before relying on it **It is a pointer to your own carrier record. It is never the proof itself**, and that distinction decides whether it helps you. For a *"I never received it"* dispute (Visa 13.1), Visa asks for **proof of delivery containing the full delivery address**, and says outright that tracking with a partial address is not permitted (Table 11-94, ID# 0030318). **We cannot supply that, ever** — your customer's address is hashed inside your own store before it reaches us, so a breach of ours cannot become a list of your customers, and the consequence of that choice is that we do not hold the address the rule requires. You produce the proof of delivery from your carrier account. **What sending this buys you** is that the tracking number and despatch date are bound to the order, in a record that cannot be edited afterwards, alongside the lot and the agreement. It is corroboration and a pointer, not the delivery proof. **Where it is worth most is a fraud dispute, not a non-receipt one** — which is the reverse of what you would expect from a field called "shipments". For *"I did not make this purchase"* the network asks for the **date the merchandise was provided**, for the disputed order and for the earlier orders the argument rests on. `shipped_at` is that date, and your fraud packet prints it. Without it the packet says the date was not reported. **It only ever covers orders from the day you start sending it.** There is no backfill and there cannot be: the record is written when you report a despatch, and the table it lands in cannot be edited afterwards. So an order placed before you wire this up carries no despatch record, permanently. If a review asks about last month and you started last week, the honest answer for those orders is that none was reported — which is what your documents will say, rather than substituting the order date. ### Not sending it is not a failure A store with no shipping plugin, or one that prints labels on a carrier's website, has nothing to send. **An order with no despatch record reads as "not reported", never as "not shipped".** No document will suggest you failed to ship something, and nothing on your dashboard turns red for it. ### On WooCommerce you get this for free The plugin reads the tracking number your shipping extension already stored and reports it on its own, the moment you save the order. It knows the meta key used by the official **WooCommerce Shipment Tracking** extension and by **Advanced Shipment Tracking** (which shares it), and **AfterShip**'s own. Another extension, or your own code, can supply one with the `provkeep_shipments` filter. --- ## Questions this document does not answer **Whether a parcel actually arrived.** We record what you tell us about despatch and nothing more. Confirming delivery means asking a carrier, which needs a parcel service we have not built (`DELIVERY_EVIDENCE_DESIGN.md` §3, option 2), and until it exists no document of ours will imply a carrier told us anything. **Anything about what your own checkout does.** We do not read it, wrap it, or describe it. If your checkout collects agreement, tell us with `consent_source` and we will say so; we will not claim to have seen it.