Docs

Running a store

DocsRunning a store

Building a remote provider

8 sections8 min readdocs/remote-providers.md

On this page8 sections
  1. 1The five-minute version
  2. 2The request
  3. 3Verifying the signature
  4. 4The response
  5. 5What happens when you fail
  6. 6Contract versioning
  7. 7The kinds
  8. 8Registering

A remote provider is an HTTPS endpoint you own that Skelet calls mid-request to make a decision: what shipping options to offer, how much tax is owed, whether an order looks fraudulent. Where a webhook observes something that already happened, a provider implements the answer - core waits for your response and uses it.

You can write one in any language. It is one POST endpoint that verifies a signature and returns JSON. Nothing is installed into Skelet, and nothing needs rebuilding or redeploying - a merchant registers the URL in the admin and it is live.

Not covered here: payment gateways. Those are deliberately not a provider kind, because an authorization needs a client secret reaching the browser for 3DS, a vault token contract, and verification of the gateway’s own inbound webhooks. Proxying that would put your endpoint in PCI scope. Gateways are first-party plugins instead.

The five-minute version

# 1. Register the provider in the admin (Settings -> Remote providers) and copy the
#    rpsec_... secret - it is shown exactly once.
# 2. Stand up one POST endpoint that verifies the signature below and answers with the
#    kind's response shape, then check it against the contract before wiring it to
#    anything real.
skelet provider --url http://localhost:9095/tax --secret rpsec_... --kind tax_provider

skelet provider posts the generated example payload for the kind, then tries the failure cases - forged signature, no signature, wrong key, an hour-old timestamp - and tells you whether your endpoint handled each correctly. The request, the signature and every kind’s payload and response are specified in the rest of this page.

The request

Core sends POST to your URL with:

HeaderValue
Content-Typeapplication/json
User-AgentRemoteProvider/1.0
X-Provider-Kindthe kind, e.g. tax_provider
X-Provider-Contract-Versionthe wire-contract version, e.g. 0.1.0
X-Provider-Slugthe slug the merchant registered
X-Provider-Timestampunix seconds
X-Provider-Signature-256sha256=<hex>

The body is:

{
  "kind": "tax_provider",
  "provider": "acme-tax",
  "contractVersion": "0.1.0",
  "timestamp": "2026-08-22T10:31:00Z",
  "config": { "apiKey": "..." },
  "payload": { }
}

config is the JSON blob the merchant saved on the provider row - credentials, tuning, whatever your service needs - forwarded verbatim and omitted when empty. payload is kind-specific and documented per kind below.

Verifying the signature

signature = HMAC_SHA256(secret, X-Provider-Timestamp + "." + raw_request_body)

Hex-encode it and compare against the header value with the sha256= prefix stripped.

Three things to get right, in the order people get them wrong:

Sign over the header timestamp, not the one in the body. There are two timestamps and they are not the same value. The body’s timestamp is RFC3339 and is generated when core builds the envelope; X-Provider-Timestamp is unix seconds and is generated later, just before the request goes out. The signature uses the header. Signing over the body’s field produces a digest that never matches, and the symptom - every request 401s, including obviously valid ones - looks like a wrong secret.

Hash the raw bytes. Verify before parsing, and hash exactly what arrived. Decoding and re-encoding the JSON reorders keys and changes whitespace, so the digest will not match.

Compare in constant time. hmac.Equal, crypto.timingSafeEqual, hmac.compare_digest

  • not ==.

Core does not cap how old a timestamp may be, so you should. The reference implementation rejects anything outside five minutes; without that, one captured request stays replayable forever.

Verification in TypeScript, Python and PHP
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the unparsed body - express: app.use(express.raw({type:"application/json"}))
export function verify(secret: string, headers: Record<string,string>, rawBody: Buffer): boolean {
  const ts = headers["x-provider-timestamp"];
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = createHmac("sha256", secret).update(ts).update(".").update(rawBody).digest("hex");
  const got = (headers["x-provider-signature-256"] ?? "").replace(/^sha256=/, "");
  const a = Buffer.from(got), b = Buffer.from(want);
  return a.length === b.length && timingSafeEqual(a, b);
}
import hmac, hashlib, time

def verify(secret: str, headers, raw_body: bytes) -> bool:
    ts = headers.get("X-Provider-Timestamp", "")
    if not ts or abs(time.time() - int(ts)) > 300:
        return False
    want = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    got = headers.get("X-Provider-Signature-256", "").removeprefix("sha256=")
    return hmac.compare_digest(got, want)
function verify(string $secret, array $headers, string $rawBody): bool {
    $ts = $headers['X-Provider-Timestamp'] ?? '';
    if ($ts === '' || abs(time() - (int)$ts) > 300) return false;
    $want = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
    $got = preg_replace('/^sha256=/', '', $headers['X-Provider-Signature-256'] ?? '');
    return hash_equals($want, $got);
}

The response

Return HTTP 2xx with a JSON body matching the kind’s response schema. Anything else - 3xx, 4xx, 5xx, a non-JSON body, an empty body - is an invocation failure.

Three limits worth knowing before you hit them:

  • 256 KiB. Core reads your response through a limit reader and stops there. A larger body is silently truncated and then fails to parse, so an oversized response reports as a malformed one. Paginate or trim rather than returning everything.
  • Your timeout. The merchant sets it per provider (default 5s, max 30s). Past it core abandons the call and applies the failure policy.
  • Null is not zero. Several response fields are nullable by design, and null means “no answer” rather than a zero value - taxCents, unitPriceCents, shippingOverrideCents, average, normalized. Returning null where you mean zero gets read as a failure. Send the number.

What happens when you fail

Failure policy, set per provider by the merchant:

  • fail_open (default) - core falls back to its own behavior. Your tax provider being down means built-in tax rules apply.
  • fail_closed - the operation aborts. Safer when a wrong answer is worse than no sale.

Some consumers pick per operation regardless of the setting. Shipping is the clearest example: quoting fails open, so a dead carrier just drops out of the list, but pricing the method the customer already chose fails closed rather than charging a guessed rate.

Circuit breaker. Five consecutive failures opens the circuit for that provider row; calls short-circuit for 30 seconds, then one is allowed through to test recovery. The admin shows breaker state, so a provider that looks “not called” may be tripped rather than unused.

Response cache. If the merchant sets a cache TTL (max 1 hour), successful responses are cached against a hash of the payload alone - the envelope timestamp is deliberately excluded, so an identical payload will not reach you again until the TTL expires. Two consequences: identical inputs must produce identical outputs, and a provider whose answer depends on wall-clock time (a rate that changes hourly, live inventory) wants a short TTL or none. Editing the provider’s config or URL flushes its cache.

Channel scope. A provider can be bound to one channel or left global. A channel-bound provider is only called for that channel’s traffic.

Contract versioning

Every request carries the wire-contract version, both as X-Provider-Contract-Version and as contractVersion inside the signed body, so nothing between core and you can strip it. The version names the shape of every payload in this document - it is a single number for the whole contract, not one per kind.

The contract is pre-1.0 and versioned accordingly:

  • a minor bump (0.1.x -> 0.2.0) means something broke: a field was removed or renamed, a type changed, a kind was removed, or core started requiring something new in responses. Re-run skelet provider against your endpoint before upgrading core.
  • a patch bump means additive only: a new kind, or a new optional field. Nothing you already handle changed shape.

Two habits keep you safe across patch bumps: ignore unknown JSON fields (Go’s default), and never treat an absent optional field as an error. Log the version you receive - when a payload surprises you, the first question is which contract it was built under, and core’s side of the guarantee is machine-enforced: the generated schemas cannot change without a version bump.

The kinds

Machine-readable schemas and a ready-to-post example for every operation live in apps/backend/core/internal/remoteprovider/contract/ as <name>.schema.json and <name>.request.json. They are generated from core’s own structs by skelet gen, so they cannot drift from what core actually sends.

KindWhat you decide
shipping_carrierRate a basket to a destination; the options the shopper picks from
delivery_pointsSearch and resolve PO boxes and lockers for a PO box delivery option; see checkout.md
shipping_trackingCarrier tracking status for a shipment
tax_providerTax owed on an order
address_validatorWhether an address is valid, plus a normalized form
promotion_conditionWhether a promotion rule matches the cart
promotion_actionThe discount a matched rule applies
pricingOverride a line’s unit price
currency_rateFX rates for a base currency
fraud_riskApprove or reject an order
returns_policyApprove or refuse a return
inventory_availabilitySalable quantity per product (-1 = unlimited)
recommendationRelated products for a product page
reviewsReview aggregates from an external platform
searchFree-text search and faceted browse - two operations, one kind
customer_segmentsSegment slugs a customer belongs to

Three notes that cost debugging time:

  • search is two operations. A provider registered for search must implement both search (typeahead and /search) and search.browse (category and filter listings). Dispatch on the payload shape, or on X-Provider-Kind plus the presence of filter.
  • Addresses are not one shape. address_validator uses state; the tax, fraud and shipping addresses use region. Same concept, different field name - do not share a struct across kinds without checking the schema.
  • fraud_risk approves by default. Only the exact string "reject" in decision blocks an order. Any other value, including a typo or an empty string, approves it.

Registering

Admin, under Remote providers - needs the remote_providers.manage permission. You set the kind, a slug, the target URL, the config blob, timeout, cache TTL, failure policy, and optionally a channel. The rpsec_ signing secret is displayed once on creation; rotating it issues a new one and invalidates the old.

Then, before you point production at it:

skelet provider --url https://your-provider.example.com/hook \
  --secret rpsec_... --kind tax_provider --timeout-ms 5000

Pass --payload FILE to test with your own request body instead of the generated example, and --config FILE to include the config blob your endpoint expects.