Add protection to
your own checkout.
For a developer connecting a store that no plugin covers. Your server talks to Post Coverage at five moments; this page shows what to do at each one and why, in Node.js, PHP or plain HTTP.
Sandbox only today. The API issues no live coverage, and every quote and order it returns is marked sandbox. Do not charge a real customer a protection fee until Post Coverage confirms an approved merchant agreement for your store. Never send customer names, email addresses, phone numbers, street addresses or payment details.
Choose your path.
Every path uses the same API. Pick the one that matches how your store is built; the WordPress plugins need no code at all.
Shopify app
A Shopify app is in development, for development stores only. Tell your Post Coverage contact that you sell on Shopify, and which Shopify plan you are on.
WooCommerce plugin
Install it and add your key. It puts the opt-in module above Place Order in the classic and block checkouts, records each paid order and keeps delivery events in step.
Medusa plugin
Offers protection at checkout, records each order, sends delivery events with their tracking numbers and emails the customer their claim link. Ask your Post Coverage contact for the package.
Claim lookup plugin
Lets customers check a claim on your own pages with a shortcode or a block. Works beside any of the other paths.
Node.js library
One file, post-coverage.mjs, with no dependencies and with TypeScript declarations. The guide walks through every call.
PHP library
One file, post-coverage.php. It needs only curl and json, no Composer, and is namespaced so it sits safely beside WordPress.
REST API
HTTPS and JSON from your server, described in openapi.json. The guide shows each raw request next to the library code.
Before you start.
- Sign up, set up your store’s workspace, and generate a sandbox key (it starts
pc_test_) in Integrations. - Put the API origin in your server’s environment as
PC_API_URL: it is shown as “API base” under the key when you generate it. Put the key inPC_API_KEY. Neither goes in a page, a theme or a browser script: anyone who has the key can call the API as your store, from anywhere. - Download the library for your language from the developer page or use the raw requests. Choose a language once below and every example on this page follows it.
// Server-side only: the key must never reach a browser. Keep both values in
// your server's environment or secret store.
import {
PostCoverageClient,
PostCoverageError,
privateClaimLink,
} from "./post-coverage.mjs";
// At checkout: one attempt and a short timeout. A failed quote means no
// offer, never a retry, because every attempt spends your server's share of
// 40 requests per 5 minutes.
const checkout = new PostCoverageClient({
baseUrl: process.env.PC_API_URL,
apiKey: process.env.PC_API_KEY,
timeoutMs: 4000,
retries: 0,
});
// Everything else (recording orders, events, claim links): the defaults.
// The library never retries order creation or claim-link resets.
const background = new PostCoverageClient({
baseUrl: process.env.PC_API_URL,
apiKey: process.env.PC_API_KEY,
});// Server-side only: the key must never reach a browser. Keep both values in
// your server's environment or secret store.
require_once __DIR__ . '/post-coverage.php';
use PostCoverage\ApiError;
use PostCoverage\Client;
use function PostCoverage\privateClaimLink;
// At checkout: one attempt and a short timeout. A failed quote means no
// offer, never a retry, because every attempt spends your server's share of
// 40 requests per 5 minutes.
function checkoutClient(): Client {
static $client = null;
return $client ??= new Client(getenv('PC_API_URL'), getenv('PC_API_KEY'),
['timeoutMs' => 4000, 'retries' => 0]);
}
// Everything else (recording orders, events, claim links): the defaults.
// The library never retries order creation or claim-link resets.
function backgroundClient(): Client {
static $client = null;
return $client ??= new Client(getenv('PC_API_URL'), getenv('PC_API_KEY'));
}Every request goes from your server to
{{origin}}/integrations/v1/<route>
with these headers:
Content-Type: application/json
X-API-Key: {{apiKey}}
Amounts are whole US cents. Unknown fields are refused.Two clients, because the two jobs need different habits. Checkout must answer fast and give up quietly. Background work can wait and retry, except for the two calls that return a once-only code, which the libraries never retry.
The five moments.
- Quote at checkout and show the offer
- Record the order after payment
- Keep the access code and send the claim link
- Report what happens to the order
- Reissue a lost claim link
Quote the order and show the offer.
Ask Post Coverage for the fee, show the offer directly above the place-order button, and charge exactly what you showed.
- Quote the merchandise subtotal after discounts, in cents, of the items that ship. Leave out shipping, tax, the protection fee itself and digital items: they are not covered. A cart with nothing to ship gets no offer.
- Show and charge exactly
feeCents. Never compute it yourself: Post Coverage sets the price (by default 3.5% of the amount, rounded up to the cent, with a $0.99 minimum; your store’s pricing may differ), and it can change. - Quote again whenever the amount changes. Keep each answer for
expiresInseconds in a cache all your servers share, so showing the same amount again costs no request. - One attempt, a short timeout. If the quote fails for any reason, show no offer and add no fee. The checkout carries on without it.
- When the customer places the order with protection on, quote once more, past the cache:
expiresInis advisory, and the price can change while a cached answer is still inside it. If the fee moved, show the new one and ask again. If the quote fails, take protection off and ask them to confirm the order without it. That is one more request per protected order.
Why: every request, quotes included, counts against 40 per five minutes for your server’s address. A checkout that quotes on every page view runs out. Failing closed means nobody is ever charged a fee they did not see.
// The amount to protect: merchandise after discounts, in cents. Never
// shipping, tax or the protection fee itself.
function protectedAmountCents(cart) {
const items = cart.lines.reduce((sum, line) => sum + line.unitCents * line.quantity, 0);
return items - cart.discountCents;
}
// One quote per amount, for as long as the API says it holds. In production
// use a cache every server process shares (Redis, your database). `fresh`
// skips the cache, for the place-order check below, and stores the answer.
const quotes = new Map();
async function offerFor(cart, { fresh = false } = {}) {
const amountCents = protectedAmountCents(cart);
const cached = quotes.get(amountCents);
if (!fresh && cached && cached.until > Date.now()) return cached.quote;
try {
const quote = await checkout.quote(amountCents);
const seconds = Math.min(Math.max(quote.expiresIn, 60), 900);
quotes.set(amountCents, { quote, until: Date.now() + seconds * 1000 });
return quote;
} catch {
// Fail closed: no offer and no fee. The same for an outage, a timeout,
// the rate limit, or an amount below $1.00.
return null;
}
}// The amount to protect: merchandise after discounts, in cents. Never
// shipping, tax or the protection fee itself.
function protectedAmountCents(array $cart): int {
$items = 0;
foreach ($cart['lines'] as $line) {
$items += $line['unitCents'] * $line['quantity'];
}
return $items - $cart['discountCents'];
}
// One quote per amount, for as long as the API says it holds. In production
// use a cache every PHP worker shares (APCu, Redis, a WordPress transient).
// $fresh skips the cache, for the place-order check below, and stores the
// answer.
function offerFor(array $cart, bool $fresh = false): ?array {
static $quotes = [];
$amountCents = protectedAmountCents($cart);
$cached = $quotes[$amountCents] ?? null;
if (!$fresh && $cached !== null && $cached['until'] > time()) {
return $cached['quote'];
}
try {
$quote = checkoutClient()->quote($amountCents);
} catch (\Throwable $error) {
// Fail closed: no offer and no fee. The same for an outage, a timeout,
// the rate limit, or an amount below $1.00.
return null;
}
$seconds = min(max((int) $quote['expiresIn'], 60), 900);
$quotes[$amountCents] = ['quote' => $quote, 'until' => time() + $seconds];
return $quote;
}POST {{origin}}/integrations/v1/quotes
Content-Type: application/json
X-API-Key: {{apiKey}}
{
"amountCents": 7830,
"currency": "USD"
}The answer carries feeCents, pricingVersion (keep it with the order), currency, environment, expiresIn, notice and reward. reward is null unless the store runs a reward program, and a missing reward means the same as null. It is a preview: the credit actually earned is on the order once it is recorded. Show it only if the order will be recorded with a customerKey (without one, no credit is earned) and your store applies the credit at a later checkout; see rewards. The examples do not show it.
// What the checkout shows, directly above the place-order button. It starts
// OFF on every visit; only the customer's own action turns it on.
//
// showReward: mention the quote's store-credit preview only if the order
// will be recorded with a customerKey (no key, no credit) and your store
// applies Post Coverage credit at a later checkout. This example's store
// does not, so it passes false.
function offerView(quote, optedIn, showReward) {
if (!quote) return null; // no quote, no module, no fee
return {
title: "Protect this order",
price: `+${dollars(quote.feeCents)}`,
reward: showReward && quote.reward
? `earns ${dollars(quote.reward.amountCents)} in store credit`
: null,
body: optedIn
? ["Lost, stolen or damaged", "Replace or refund, your pick"]
: ["No protection added."],
};
}// What the checkout shows, directly above the place-order button. It starts
// OFF on every visit; only the customer's own action turns it on.
//
// $showReward: mention the quote's store-credit preview only if the order
// will be recorded with a customerKey (no key, no credit) and your store
// applies Post Coverage credit at a later checkout. This example's store
// does not, so it passes false.
function offerView(?array $quote, bool $optedIn, bool $showReward): ?array {
if ($quote === null) {
return null; // no quote, no module, no fee
}
$reward = $showReward ? ($quote['reward'] ?? null) : null;
return [
'title' => 'Protect this order',
'price' => '+' . dollars($quote['feeCents']),
'reward' => $reward ? 'earns ' . dollars($reward['amountCents']) . ' in store credit' : null,
'body' => $optedIn
? ['Lost, stolen or damaged', 'Replace or refund, your pick']
: ['No protection added.'],
];
}What the checkout shows is up to your templates. The rules:
Protection starts OFF on every visit.
OFF reads exactly: No protection added.
ON may list only: Lost, stolen or damaged
Replace or refund, your pick
The price is +feeCents from the quote.
A reward only if your store applies the credit,
called store credit, never cash.
See the checkout requirements further down.Then, when the customer places the order with protection on, check the fee once more before taking payment:
// The customer presses place order with protection on. Quote once more,
// past the cache: the price can change while a cached quote is still within
// its expiresIn, which is advisory. Charge only a fee the customer saw.
async function confirmAtPlaceOrder(cart, shown) {
const now = await offerFor(cart, { fresh: true });
// No price: take protection off and ask the customer to confirm without it.
if (!now) return { state: "ask_again", quote: null };
// A new fee: show it and ask again. Nothing has been charged.
if (now.feeCents !== shown.feeCents) return { state: "ask_again", quote: now };
// The fee they saw. Charge it, and keep this quote's pricingVersion.
return { state: "confirmed", quote: now };
}// The customer presses place order with protection on. Quote once more,
// past the cache: the price can change while a cached quote is still within
// its expiresIn, which is advisory. Charge only a fee the customer saw.
function confirmAtPlaceOrder(array $cart, array $shown): array {
$now = offerFor($cart, true);
if ($now === null) {
// No price: take protection off and ask the customer to confirm without it.
return ['state' => 'ask_again', 'quote' => null];
}
if ($now['feeCents'] !== $shown['feeCents']) {
// A new fee: show it and ask again. Nothing has been charged.
return ['state' => 'ask_again', 'quote' => $now];
}
// The fee they saw. Charge it, and keep this quote's pricingVersion.
return ['state' => 'confirmed', 'quote' => $now];
}At place order, with protection on, send POST
/integrations/v1/quotes for the same amountCents, past your cache.
the same feeCents as shown -> charge it, and send this
quote's pricingVersion
a different feeCents -> show the new fee and ask again;
nothing is charged yet
no answer -> take protection off and ask againRecord the order.
Once payment has gone through, tell Post Coverage about the order: protected, declined and ineligible orders alike. Reports describe the orders they receive, so a store that sends only protected orders cannot show how many customers chose protection.
externalIdis your own reference for the order: unique in your store and never reused, 4 to 80 letters, digits,-or_. Prefix short order numbers, for exampleORD-123. Keep it opaque, never an email or a name.customerKeyis an opaque key that is the same for every order a customer places: an HMAC of their account id, or of their lower-cased email for a guest, under a secret that stays on your server. It lets store credit follow the customer; an order without one earns none. Never send the email itself.customerOptedInis true only when the protection fee is on the order the customer paid for. Send the quote’spricingVersionwith a protected order. UsepurchaseType: "recurring"for subscription orders, with a newexternalIdfor each one, and addcoverageEligible: falseto an order that could never be offered protection.- Never send a customer’s name, email, phone number, address or payment details. Fields the API does not know are refused.
- Send the order’s products as
items(see below), so the customer can say which ones a claim is about. - Order creation is never retried automatically. A protected order’s answer carries a code that is shown once, and a blind retry could lose it.
// A stable, opaque customer key: an HMAC under a secret that never leaves
// your server. Account holders are keyed on their account id, so a changed
// email does not split their history; guests on the lower-cased email.
function customerKey(customer, secret) {
const [prefix, basis] = customer.accountId
? ["CU", `account:${customer.accountId}`]
: ["CG", `email:${customer.email.trim().toLowerCase()}`];
return prefix + createHmac("sha256", secret).update(basis).digest("hex").slice(0, 40);
}
// Build the body when the order is paid and store it with the order: a
// reconciliation later must send exactly the body that was last sent.
function orderBody(order, secret) {
const protectedOrder = order.protectionFeeCents > 0; // the fee line charged
return {
externalId: order.externalId, // your reference: 4 to 80 characters, never reused
amountCents: order.protectedAmountCents,
currency: "USD",
purchaseType: order.recurring ? "recurring" : "one_time",
customerOptedIn: protectedOrder,
customerKey: customerKey(order.customer, secret),
...(protectedOrder ? { pricingVersion: order.pricingVersion } : {}),
};
}
async function recordOrder(body) {
for (let attempt = 1; ; attempt++) {
try {
// A replayed answer never carries the code: see keepCode, moment 3.
return { state: "recorded", result: await background.createOrder(body) };
} catch (error) {
if (!(error instanceof PostCoverageError)) throw error;
if (error.outcomeUnknown) return { state: "unknown" }; // it may exist: reconcile, below
if (error.status === 409 && error.message.startsWith("Pricing changed"))
return { state: "pricing_changed" }; // nothing was written
// Two writes met at the same moment, for example a double submit.
// Nothing from this request was written: send the same body once more.
if (attempt === 1 && error.status === 409
&& error.message.startsWith("An order with this reference was just created"))
continue;
throw error;
}
}
}// A stable, opaque customer key: an HMAC under a secret that never leaves
// your server. Account holders are keyed on their account id, so a changed
// email does not split their history; guests on the lower-cased email.
function customerKey(array $customer, string $secret): string {
[$prefix, $basis] = isset($customer['accountId'])
? ['CU', 'account:' . $customer['accountId']]
: ['CG', 'email:' . strtolower(trim($customer['email']))];
return $prefix . substr(hash_hmac('sha256', $basis, $secret), 0, 40);
}
// Build the body when the order is paid and store it with the order: a
// reconciliation later must send exactly the body that was last sent.
function orderBody(array $order, string $secret): array {
$protected = $order['protectionFeeCents'] > 0; // the fee line charged
$body = [
'externalId' => $order['externalId'], // your reference: 4 to 80 characters, never reused
'amountCents' => $order['protectedAmountCents'],
'currency' => 'USD',
'purchaseType' => $order['recurring'] ? 'recurring' : 'one_time',
'customerOptedIn' => $protected,
'customerKey' => customerKey($order['customer'], $secret),
];
if ($protected) {
$body['pricingVersion'] = $order['pricingVersion'];
}
return $body;
}
function recordOrder(array $body): array {
for ($attempt = 1; ; $attempt++) {
try {
// A replayed answer never carries the code: see keepCode, moment 3.
return ['state' => 'recorded', 'result' => backgroundClient()->createOrder($body)];
} catch (ApiError $error) {
if ($error->outcomeUnknown) {
return ['state' => 'unknown']; // it may exist: reconcile, below
}
if ($error->status === 409 && str_starts_with($error->getMessage(), 'Pricing changed')) {
return ['state' => 'pricing_changed']; // nothing was written
}
// Two writes met at the same moment, for example a double submit.
// Nothing from this request was written: send the same body once more.
if ($attempt === 1 && $error->status === 409
&& str_starts_with($error->getMessage(), 'An order with this reference was just created')) {
continue;
}
throw $error;
}
}
}POST {{origin}}/integrations/v1/orders
Content-Type: application/json
X-API-Key: {{apiKey}}
{
"externalId": "{{externalId}}",
"amountCents": 7830,
"currency": "USD",
"purchaseType": "one_time",
"customerOptedIn": true,
"customerKey": "{{customerKey}}",
"pricingVersion": "{{pricingVersion}}"
}The order’s items.
Optional, and descriptive only: items never changes amountCents, which stays the amount the fee was quoted on. Send 1 to 100 products in at most 32 KB of JSON (about 32,000 bytes); with more, send the first ones and itemsTruncated: true. The libraries’ cleanItems does this for you.
| Field | What it holds |
|---|---|
name | The product as the store names it, 1–200 characters. |
sku, variant | Optional: up to 100 and 120 characters. |
lineId | Optional: your order line’s id, 1–64 letters, numbers or _ . : -. |
quantity | A whole number from 1 to 100,000. |
unitPriceCents | The unit price in cents. |
requiresShipping | false for a digital item. Digital items are not covered: the fee base is shippable items only, and an order with nothing to ship is not eligible. |
Text may be in any language and may hold emoji; control characters are refused. Post Coverage shows the items to the customer filing a claim (without prices) and to its team.
Store the body with the order before sending it: every later attempt must send exactly the body that was last sent.
If the outcome is unknown (a timeout, a dropped connection, a 5xx: the libraries flag it as outcomeUnknown), the order may or may not exist. Mark it, then send the stored body once more as a deliberate step, from a scheduled job or a person, never from a retry loop. If the order had arrived, the answer says replayed: true and carries no code. If you stored no code for it, the first one is lost: issue a new claim link (moment 5). If the order had not arrived, it is created now and the code comes with it.
If the answer is 409 “Pricing changed” for an order the customer has already paid, nothing was written. Quote the same amount again. If the fee is what the customer paid, replace the stored body with one that carries the new pricingVersion, then send it once. From then on, reconcile with that body: the API refuses a repeat whose pricingVersion differs from the recorded one. If the fee is not what the customer paid, a person decides: record it at the current price and settle the difference with the customer, or wait. Never leave pricingVersion out to force it through; the order would then carry a fee the customer did not pay.
If the answer is 409 “An order with this reference was just created”, two requests for the same order met, for example a double submit. Nothing from yours was written: send the same body once more. If it comes back replayed: true, the code went to the other request. Read your record for the order again: if that request saved the code, use it, because its link may already be on its way to the customer. Only if no code was saved, issue a new claim link (moment 5). Any other 409 “This externalId already exists with different details” means a reference was reused or a stored body was not the one last sent: a bug to fix, not something to retry.
// Moment 2 for a paid order. `stored` is your record of it: the body and the
// fee the customer paid. Pricing changed (409) means nothing was written:
// quote the same amount again, and if the fee is what the customer paid,
// send the order once more under the new pricingVersion, saving that body in
// place of the old one first. Otherwise a person decides. Never drop
// pricingVersion to force it through.
//
// After an unknown outcome, call this again for the same stored record, as a
// deliberate step (a scheduled job or a person), never from a retry loop.
// externalId makes it safe: an order that exists comes back replayed and
// without its code; one that never arrived is created now, with its code.
async function recordPaidOrder(stored) {
const outcome = await recordOrder(stored.body);
if (outcome.state !== "pricing_changed") return outcome;
const fresh = await background.quote(stored.body.amountCents);
if (fresh.feeCents !== stored.feeCents)
return { state: "needs_decision", currentFeeCents: fresh.feeCents };
stored.body = { ...stored.body, pricingVersion: fresh.pricingVersion }; // save first
return recordOrder(stored.body);
}// Moment 2 for a paid order. $stored is your record of it: the body and the
// fee the customer paid. Pricing changed (409) means nothing was written:
// quote the same amount again, and if the fee is what the customer paid,
// send the order once more under the new pricingVersion, saving that body in
// place of the old one first. Otherwise a person decides. Never drop
// pricingVersion to force it through.
//
// After an unknown outcome, call this again for the same stored record, as a
// deliberate step (a scheduled job or a person), never from a retry loop.
// externalId makes it safe: an order that exists comes back replayed and
// without its code; one that never arrived is created now, with its code.
function recordPaidOrder(array &$stored): array {
$outcome = recordOrder($stored['body']);
if ($outcome['state'] !== 'pricing_changed') {
return $outcome;
}
$fresh = backgroundClient()->quote($stored['body']['amountCents']);
if ($fresh['feeCents'] !== $stored['feeCents']) {
return ['state' => 'needs_decision', 'currentFeeCents' => $fresh['feeCents']];
}
$stored['body']['pricingVersion'] = $fresh['pricingVersion']; // save first
return recordOrder($stored['body']);
}Unknown outcome: later, send the stored POST
/integrations/v1/orders body again, byte for byte, once.
replayed: false, accessCode: ... -> it is created now
replayed: true, accessCode: null -> it existed. Use the code
you stored; if none was
stored, reissue (moment 5)
409 Pricing changed: POST /integrations/v1/quotes for the same
amountCents. The same feeCents as the customer paid: store the
body with the new pricingVersion in place of the old one, then
send it once. Reconcile with that body from then on.
Otherwise, a person decides.
409 An order with this reference was just created: send the
same body once more. replayed: true -> as above.Keep the access code and send the claim link.
A protected order’s answer carries accessCode, once. It is the customer’s key to their claim, so treat it like a password.
- Save the whole answer before anything else, with the code sealed (encrypted with a key from your secret store). Never log it, show it in an admin screen, or put it in analytics or an error report.
- An answer with
replayed: truecarries no code. Keep the code you stored: its link still works. Only if none was stored is a new link needed (moment 5). - Give the customer their private link:
https://www.postcoverage.com/claims/#order=<orderId>&code=<accessCode>. The code goes after the#, never in a query string, so it never reaches a server log. Usewww.: the bare domain does not serve claim links. - Send it in the order confirmation or show it in the customer’s account, through transactional email with click tracking and link rewriting turned off.
// The access code is a credential, returned once. Seal it before it is
// stored, with a 32-byte key from your secret store. Never log it.
function seal(accessCode, key) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const sealed = Buffer.concat([cipher.update(accessCode, "utf8"), cipher.final()]);
return "v1." + Buffer.concat([iv, cipher.getAuthTag(), sealed]).toString("base64url");
}
function unseal(value, key) {
const raw = Buffer.from(value.slice(3), "base64url");
const decipher = createDecipheriv("aes-256-gcm", key, raw.subarray(0, 12));
decipher.setAuthTag(raw.subarray(12, 28));
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString("utf8");
}
// After recording or reconciling: seal the code if this answer has one.
// A replayed answer has none, so read your own record for the order again:
// if an earlier request saved a code, it still works. Only when none was
// saved is a new link needed (moment 5): a reset stops every earlier link,
// including one already emailed.
function keepCode(stored, result, key) {
if (result.accessCode) stored.sealedCode = seal(result.accessCode, key); // persist first
return { needsNewLink: result.order.customerOptedIn && !stored.sealedCode };
}
// For the order confirmation. The code travels in the fragment (#) of a link
// to www.postcoverage.com, which never reaches a server log. Send it in a
// transactional email with click tracking off; never in analytics.
function claimLink(orderId, sealedCode, key) {
return privateClaimLink({ orderId, accessCode: unseal(sealedCode, key) });
}// The access code is a credential, returned once. Seal it before it is
// stored, with a 32-byte key from your secret store. Never log it.
function seal(string $accessCode, string $key): string {
$iv = random_bytes(12);
$sealed = openssl_encrypt($accessCode, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($sealed === false) {
throw new \RuntimeException('Could not seal the access code.');
}
return 'v1.' . rtrim(strtr(base64_encode($iv . $tag . $sealed), '+/', '-_'), '=');
}
function unseal(string $value, string $key): string {
$raw = base64_decode(strtr(substr($value, 3), '-_', '+/'), true);
$code = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $key, OPENSSL_RAW_DATA,
substr($raw, 0, 12), substr($raw, 12, 16));
if ($code === false) {
throw new \RuntimeException('Could not unseal the access code.');
}
return $code;
}
// After recording or reconciling: seal the code if this answer has one.
// A replayed answer has none, so read your own record for the order again:
// if an earlier request saved a code, it still works. Only when none was
// saved is a new link needed (moment 5): a reset stops every earlier link,
// including one already emailed.
function keepCode(array &$stored, array $result, string $key): array {
if (!empty($result['accessCode'])) {
$stored['sealedCode'] = seal($result['accessCode'], $key); // persist first
}
return ['needsNewLink' => $result['order']['customerOptedIn'] && $stored['sealedCode'] === null];
}
// For the order confirmation. The code travels in the fragment (#) of a link
// to www.postcoverage.com, which never reaches a server log. Send it in a
// transactional email with click tracking off; never in analytics.
function claimLink(string $orderId, string $sealedCode, string $key): string {
return privateClaimLink($orderId, unseal($sealedCode, $key));
}https://www.postcoverage.com/claims/#order={{orderId}}&code={{accessCode}}Report what happens to the order.
Tell Post Coverage when the order ships, arrives, is cancelled or is refunded, so a claim is judged against what really happened.
| type | When |
|---|---|
in_transit | It has shipped. |
delivered | The carrier delivered it. It cannot go back into transit. |
exception | The carrier reported a problem. |
cancelled | Cancelled before delivery and before any refund. After either, send a refund instead. |
refunded | Money went back, with refundAmountCents: this refund only, not a running total, and only the merchandise part after discounts. Never shipping, fees or tax. |
fee_refunded | You refunded the protection fee. Send no amount when the whole fee went back: protection on the order then ends, shipped or not, and any open claim is closed. For part of the fee, send feeRefundCents; protection stays. A refund of merchandise and the fee together is two events: refunded, then fee_refunded. |
- Give each event its own
requestIdwhen you save it in your database, and send that same id on every retry. A repeat is then recognised and answeredreplayed: trueinstead of being recorded twice. - Send
expectedVersion: the order’sversionfrom the last answer you stored (order creation, events and claim-link resets return the order), so an event costs one request. A store-credit redemption changes the version without returning the order; the re-read below catches that. On 409 “Order changed”, or “This record changed or already exists” when two writes met, read the order and resend once with the samerequestId. Any other 409 (“This order is closed”, “The refund exceeds the remaining order value” and so on) goes to a person. “Reverse test credit redemptions…” means reward credit was applied to the order: reverse it first (see rewards). - Send events from a queue, not from the page that caused them, and pause the queue while the current five-minute window has 16 or fewer requests left, so a customer at checkout can still be quoted. See limits.
- Sandbox only for now: every order in the sandbox is a test order, and events are accepted only with
"synthetic": true, which the libraries always send. The examples’sendEventis for those test orders. Orders from real customers will need live events, which do not exist yet.
// Mint an event's requestId when you save the event in your own database,
// and send that same id on every retry. A fresh id per attempt could record
// one refund twice.
function newEvent(orderId, type, refundAmountCents) {
const requestId = "EV" + randomBytes(16).toString("hex");
return { orderId, type, requestId, ...(type === "refunded" ? { refundAmountCents } : {}) };
}
// expectedVersion: the order's version from the last answer you stored
// (order creation, events and resets return the order), so an event costs
// one request. If that is stale ("Order changed", or "This record changed"
// when two writes met), read the order and resend once with the same
// requestId. Any other 409 goes to a person.
async function sendEvent(event, knownVersion) {
let expectedVersion = knownVersion;
for (let attempt = 1; ; attempt++) {
try {
return await background.orderEvent({ ...event, expectedVersion });
} catch (error) {
const stale = error instanceof PostCoverageError && error.status === 409
&& /^(Order changed|This record changed)/.test(error.message);
if (!stale || attempt > 1) throw error;
expectedVersion = (await background.order(event.orderId)).order.version;
}
}
}// Mint an event's requestId when you save the event in your own database,
// and send that same id on every retry. A fresh id per attempt could record
// one refund twice.
function newEvent(string $orderId, string $type, ?int $refundAmountCents = null): array {
$event = ['orderId' => $orderId, 'type' => $type, 'requestId' => 'EV' . bin2hex(random_bytes(16))];
if ($type === 'refunded') {
$event['refundAmountCents'] = $refundAmountCents;
}
return $event;
}
// expectedVersion: the order's version from the last answer you stored
// (order creation, events and resets return the order), so an event costs
// one request. If that is stale ("Order changed", or "This record changed"
// when two writes met), read the order and resend once with the same
// requestId. Any other 409 goes to a person.
function sendEvent(array $event, int $knownVersion): array {
$expectedVersion = $knownVersion;
for ($attempt = 1; ; $attempt++) {
try {
return backgroundClient()->orderEvent($event + ['expectedVersion' => $expectedVersion]);
} catch (ApiError $error) {
$stale = $error->status === 409
&& preg_match('/^(Order changed|This record changed)/', $error->getMessage()) === 1;
if (!$stale || $attempt > 1) {
throw $error;
}
$expectedVersion = backgroundClient()->order($event['orderId'])['order']['version'];
}
}
}GET {{origin}}/integrations/v1/orders/detail?orderId={{orderId}}&pageSize=100
X-API-Key: {{apiKey}}
###
POST {{origin}}/integrations/v1/orders/events
Content-Type: application/json
X-API-Key: {{apiKey}}
{
"orderId": "{{orderId}}",
"type": "delivered",
"requestId": "{{requestId}}",
"expectedVersion": {{version}},
"synthetic": true
}
###
POST {{origin}}/integrations/v1/orders/events
Content-Type: application/json
X-API-Key: {{apiKey}}
{
"orderId": "{{orderId}}",
"type": "refunded",
"refundAmountCents": 2160,
"requestId": "{{requestId}}",
"expectedVersion": {{version}},
"synthetic": true
}Reissue a lost claim link.
No code was stored for a protected order (moment 2), a customer has lost their link and you hold no code to send it again, or a link went to the wrong person. Issue a new one: every earlier link stops working, including one already sent, and the claim and its conversation are kept.
- Check who is asking first, through your own support process. The link opens the customer’s claim.
- Send a new
requestIdand the currentversion. The answer carries the new code, once: seal it, replace the stored one, and send the new link. - Never retried automatically. If the outcome is unknown, read the order later: the same version means nothing changed and the old link still works; a higher one means it may have changed, so reissue on purpose. An answer with
accessCode: nullis a replay of an earlier reset and cannot show that code again. - Sandbox only for now, like events.
// A new claim link, when no code is stored for the order or the old link
// must stop working. Check who is asking first, through your own support
// process. One press, one request: never retried automatically.
async function reissueClaimLink(orderId, key) {
const { order } = await background.order(orderId);
const requestId = "RS" + randomBytes(16).toString("hex"); // save before sending
try {
const result = await background.resetClaimAccess({
orderId, requestId, expectedVersion: order.version,
});
if (!result.accessCode) return { state: "needs_reset" }; // a replay: no code
// Replace the stored code and version. Every earlier link has stopped.
return { state: "issued", sealedCode: seal(result.accessCode, key), version: result.order.version };
} catch (error) {
if (error instanceof PostCoverageError && error.outcomeUnknown)
// Read the order later. Same version: nothing changed and the old link
// still works. Higher: the old link may be dead, so reissue on purpose.
return { state: "unknown", versionBefore: order.version };
throw error;
}
}// A new claim link, when no code is stored for the order or the old link
// must stop working. Check who is asking first, through your own support
// process. One press, one request: never retried automatically.
function reissueClaimLink(string $orderId, string $key): array {
$order = backgroundClient()->order($orderId)['order'];
$requestId = 'RS' . bin2hex(random_bytes(16)); // save before sending
try {
$result = backgroundClient()->resetClaimAccess([
'orderId' => $orderId, 'requestId' => $requestId, 'expectedVersion' => $order['version'],
]);
} catch (ApiError $error) {
if ($error->outcomeUnknown) {
// Read the order later. Same version: nothing changed and the old link
// still works. Higher: the old link may be dead, so reissue on purpose.
return ['state' => 'unknown', 'versionBefore' => $order['version']];
}
throw $error;
}
if (empty($result['accessCode'])) {
return ['state' => 'needs_reset']; // a replay: no code
}
// Replace the stored code and version. Every earlier link has stopped.
return ['state' => 'issued', 'sealedCode' => seal($result['accessCode'], $key), 'version' => $result['order']['version']];
}POST {{origin}}/integrations/v1/orders/claim-access
Content-Type: application/json
X-API-Key: {{apiKey}}
{
"orderId": "{{orderId}}",
"requestId": "{{requestId}}",
"expectedVersion": {{version}},
"synthetic": true
}Store credit from a claim.
Post Coverage settles some claims with store credit that it funds: a code your customer spends at your store, like a gift card. It never expires, it can be spent over several orders, and it works for guests. Your checkout honours it as a discount, and Post Coverage makes the discount good.
- Codes look like
PC-7KQ2-M9XD-4TPA-8HJN. Let customers type one into your coupon field.looksLikeCreditCode()in both libraries tells a Post Coverage code from your own coupons; case, spaces and dashes do not matter. credits/checkanswers what is left (balanceCents) and changes nothing. Apply at most that, and never more than the order total.- When the order is placed, call
credits/redeemwith the code, the amount, your order number (orderReference) and arequestIdyou store with the order and reuse on retries. A 409 “This store credit has $X left.” means another checkout spent it first: remove the discount and tell the customer before taking payment. - If that order fails, is cancelled or is refunded, call
credits/reversewith the samerequestId: the whole amount goes back on the code. - A code works only at the store whose claim issued it. Never keep, log or email the whole code once the order is placed: keep the redemption, the amount and
codeLast4.
Sandbox only: redeem and reverse need "synthetic": true, which the libraries add. The customer sees the whole code on their claim page; you and Post Coverage staff only ever see its last four characters.
Rewards.
If your store runs a reward program, a protected order earns reward credit toward a later order, and the quote previews it. Show that preview only if your store can honour it:
- The order is recorded with a
customerKey. Without one, no credit is earned, whatever the quote previewed. - Your checkout applies the credit to a later order for the same
customerKey, as a discount on the merchandise. The reward earned, with its id, amount, and when it becomes available and expires, is on the order fromGET /integrations/v1/orders/detail, andGET /integrations/v1/rewardslists your whole ledger. - Once that later order is recorded, call
rewards/redeemwith the reward’s id and version, the new order’s id and the amount applied. Before sending a cancelled or refunded event for that order, callrewards/reversefor the redemption. - Call it store credit, never cash back or a refund to a card.
Sandbox only: redeem and reverse need "synthetic": true, like events, and have no live version yet. How a store of your own applies the credit at its checkout is not settled, so the examples on this page leave the reward out. Ask Post Coverage before promising credit to customers.
Checkout requirements.
Every integration must meet these. They are Post Coverage’s product and compliance rules, not preferences, and no setting may change them.
- At checkout only, directly above the place-order button. Not on the cart page, in a mini cart or on a product page.
- Off until the customer turns it on. Never pre-selected, never carried over from an earlier order, never added to a renewal automatically. Only the customer's own action turns it on.
- Off reads exactly “No protection added.” Nothing more: no warning, and nothing that suggests the store is not responsible for the delivery.
- The whole order. Protection covers every item in it. There is no partial or per-item choice.
- The quoted fee, shown before the choice and charged exactly. Quoted by Post Coverage for the merchandise after discounts (by default 3.5%, rounded up to the cent, with a $0.99 minimum; your store's pricing may differ), and quoted again at place order. Never computed by the store.
- Only the approved benefit lines. “Lost, stolen or damaged” and “Replace or refund, your pick”. No other promises, such as response times or round-the-clock support.
- Rewards are store credit. Show the quote's reward only if the order carries a customerKey and your store applies the credit. Say store credit explicitly, for example “earns $7.83 in store credit”. Never cash back, never a refund to a card.
- Nothing may suggest live coverage exists. Today everything is sandbox. No real customer pays a protection fee until Post Coverage confirms an approved merchant agreement.
- Server-side only. The key and every call stay on your server.
No protection added.
- Lost, stolen or damaged
- Replace or refund, your pick
Limits and environment.
- Each store key has its own allowance: 60 requests a minute, with bursts of up to 120, unless Post Coverage has set your store its own. Quotes, reads and writes all count; other stores, and other sites on your host, never spend it. Every answer says where you stand:
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(seconds until it is full again). Over it, you get a 429 with the coderate_limitedand aRetry-Afterin seconds: wait that long, then send again. A busy store can ask for a higher allowance. - Keep a reserve for checkout. Hold background work (events, reconciliations, claim-link resets) while
X-RateLimit-Remainingis low, so a customer at checkout can still be quoted. Otherwise a batch of shipping updates can take the offer off your checkout for a few seconds. - Requests without a valid key (none, a wrong or revoked one) are limited by address instead: 40 per 5 minutes, in fixed windows that restart on the five-minute mark. A 429 there means wait for the next window, and check your key.
- Sandbox only. Every quote says
"environment": "sandbox"and “Illustrative sandbox price. Not an offer of live coverage.” Every order is recorded as sandbox, and no policy stands behind either. - The synthetic flag. Lifecycle events, claim-link resets and store-credit redemption and reversal require
"synthetic": true, which marks the order as a test. The libraries always send it. Without it the request is refused (400, a missing field), and with it set false, or once live mode is switched on, it is refused with 409. There is no live version of these routes yet. Every order in the sandbox is a test order, and these requests are for those. Keep any event for a real customer’s order in your own queue until live events exist; never send it flagged synthetic. - No real charges. Do not charge any real customer a protection fee until Post Coverage confirms an approved merchant agreement for your store.
- Amounts are whole US cents, 100 ($1) or more, with no maximum. USD only.
Run the whole thing.
The code on this page is cut from two complete examples that walk through all five moments against the sandbox, and from the raw requests file. Put each example beside its library and run it with your sandbox origin and key. They create synthetic orders only and never print a code or a link. Every change to them, or to the libraries, runs them again against a local copy of the API.
PC_API_URL=https://3feo7wip5b.execute-api.us-east-1.amazonaws.com PC_API_KEY=pc_test_... node checkout.mjs PC_API_URL=https://3feo7wip5b.execute-api.us-east-1.amazonaws.com PC_API_KEY=pc_test_... php checkout.php
Every route, field and error is in the OpenAPI specification, and the developer page has the plugins, the libraries and the full endpoint list.