INTEGRATION GUIDE

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 stores · in development

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 stores · no code

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 v2 stores · a little setup

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.

Any WordPress site · no code

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.

Your own store on Node.js 22+

Node.js library

One file, post-coverage.mjs, with no dependencies and with TypeScript declarations. The guide walks through every call.

Your own store on PHP 8.0+

PHP library

One file, post-coverage.php. It needs only curl and json, no Composer, and is namespaced so it sits safely beside WordPress.

Any other language

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.

from checkout.mjs
// 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,
});

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.

  1. Quote at checkout and show the offer
  2. Record the order after payment
  3. Keep the access code and send the claim link
  4. Report what happens to the order
  5. Reissue a lost claim link
MOMENT 1 · AT CHECKOUT

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.

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.

from checkout.mjs
// 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 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.

from checkout.mjs
// 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."],
  };
}

Then, when the customer places the order with protection on, check the fee once more before taking payment:

from checkout.mjs
// 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 };
}
MOMENT 2 · AFTER PAYMENT

Record 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.

from checkout.mjs
// 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;
    }
  }
}

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.

FieldWhat it holds
nameThe product as the store names it, 1–200 characters.
sku, variantOptional: up to 100 and 120 characters.
lineIdOptional: your order line’s id, 1–64 letters, numbers or _ . : -.
quantityA whole number from 1 to 100,000.
unitPriceCentsThe unit price in cents.
requiresShippingfalse 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.

from checkout.mjs
// 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 3 · CONFIRMATION

A protected order’s answer carries accessCode, once. It is the customer’s key to their claim, so treat it like a password.

from checkout.mjs
// 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) });
}
MOMENT 4 · AFTER THE ORDER

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.

typeWhen
in_transitIt has shipped.
deliveredThe carrier delivered it. It cannot go back into transit.
exceptionThe carrier reported a problem.
cancelledCancelled before delivery and before any refund. After either, send a refund instead.
refundedMoney went back, with refundAmountCents: this refund only, not a running total, and only the merchandise part after discounts. Never shipping, fees or tax.
fee_refundedYou 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.
from checkout.mjs
// 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;
    }
  }
}
MOMENT 5 · WHEN A CUSTOMER ASKS

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.

from checkout.mjs
// 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;
  }
}

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.

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:

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.

WHEN THE CHECKOUT OPENS
POST COVERAGEProtect this order+$2.75

No protection added.

AFTER THE CUSTOMER TURNS IT ON
POST COVERAGEProtect this order+$2.75
  • Lost, stolen or damaged
  • Replace or refund, your pick
The reference layout, from Post Coverage’s reference design, shown for a $78.30 order: one card directly above the place-order button, a switch that starts off, the quoted fee, “No protection added.” while it is off and the two approved benefit lines once it is on. Match this figure’s behaviour and wording, and nothing more; the look can follow your store.

Limits and environment.

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.