# SavItUp directed share requests — integration guide

**Audience:** an engineer at a business that wants verified details from a
customer *while they are on the phone with that customer*, with no forms, no
screen-share and no copying digits out of a chat window. You need no prior
knowledge of SavItUp to read this page.

**What you build:** three HTTPS calls out, and one HTTPS endpoint that
receives a small JSON POST. That is the whole integration.

If you would rather see it work before you write anything, open
<https://savitup.com/business> — the same three calls, from a browser, against
your own account.

**This page is published verbatim at
<https://savitup.com/docs/share-integration.md>**, and the decrypt page it
leans on at <https://savitup.com/docs/share-decrypt.md> — those two URLs are
the ones to bookmark and to send to a colleague. Repo paths below
(`docs/…`, `shared/…`, `backend/…`) name files inside SavItUp's own
repository; if you are outside it, ask us for anything you need from there and
we will send it — `shared/share-crypto-vectors.json`, the crypto test vectors,
comes with your onboarding pack.

Related pages: `docs/ssp-share-decrypt.md` (the sealing scheme + a runnable
Node snippet), `docs/share-requests-runbook.md` (SavItUp-internal runbook for
the whole feature), `docs/ssp-provider-onboarding.md` (the delivery-endpoint
contract you already satisfy if you take leads from us).

---

## 0. Asking for an integration

Nothing here needs a phone call to start. Fill in the **Request an
integration** form at <https://savitup.com/business#request> — business name,
who to reply to, a work email, and whatever else you want to tell us (states
you serve, the products you write, the systems you already run). We come back
within two business days with a sandbox account, and then with your own keys
and templates.

Prefer email? <support@savitup.com> reaches the same people. The form exists
because it asks the questions we would otherwise have to ask you twice.

What happens on our side: the request is recorded and opened as an issue in
our private intake repo, where it moves `intake` → `qualified` → `sandbox` →
`live`. You never see that repo; it is how we stop a request going quiet.

---

## 1. What this is

Your agent is on a call. They need the customer's ZIP, current insurer and
monthly premium, and they need them to be *what the customer actually has*,
not what the customer remembers.

1. The customer opens SavItUp → Sharing → **Get a code**, and reads out six
   characters: `K7M2P9`.
2. Your agent types that code into your software, against one of the
   **templates** SavItUp configured for you (a template is a named list of
   fields, e.g. `renewal-call`).
3. Your backend calls `POST /ssp/share/requests`. SavItUp spends the code and
   pushes the customer's phone.
4. The customer sees exactly which fields you asked for and who is asking, and
   approves field by field with their PIN.
5. The approved **values** are sealed on the customer's phone to *your* public
   key and delivered to your endpoint. SavItUp relays ciphertext it cannot
   read.
6. You poll `GET /ssp/share/requests/{requestId}` for status, or just wait for
   the delivery — both work; the poll never carries values.

The customer never types their details into your form, you never handle a
credential of theirs, and neither side has to trust a screenshot.

### What you never see

Field values you were not approved for; anything at all about a request
belonging to another business; the customer's identity beyond the fields they
approved. The status route reports **names only**, forever.

---

## 2. Prerequisites

### What you give SavItUp

| Thing | Notes |
|---|---|
| **Delivery endpoint URL** | Must be `https://`. A plain-HTTP URL is rejected when we save it and again at delivery time. |
| **Auth header name** | `x-api-key` or `Authorization` — your choice. |
| **Endpoint secret** | The value we send in that header, **verbatim**. If you chose `Authorization` and your intake wants `Bearer abc`, give us the whole string `Bearer abc` — we never prepend or reformat anything. Stored write-only: never displayed again, never returned by an API, never logged. |
| **Share public key** | A P-256 public key, raw uncompressed point (`0x04 ‖ X(32) ‖ Y(32)`, 65 bytes), base64. **Mandatory for this flow** — see §7. |
| **Templates** | For each: an id (`[a-z0-9-]{3,32}`), an internal name, a *purpose* the customer reads on their phone, the required and optional field lists, **your own name for every field**, and a default window. |

Read the key's SHA-256 fingerprint back to us over a channel you already
trust before we save it. A wrong key pasted once is a wrong key relayed
forever.

### What SavItUp gives you

- A **`providerId`**.
- An inbound key — a long opaque string — that you send as the
  **`x-savitup-key`** header. This is your credential for all three calls.
- Your **template ids** (also discoverable at runtime, §4.1).
- The **API base URL**. Production today is
  `https://v6xg2xoukj.execute-api.us-east-1.amazonaws.com` (there is no
  `api.savitup.com` alias yet).

---

## 3. Auth

Every call carries one header:

```
x-savitup-key: <your inbound key>
```

It is matched against the SHA-256 hash of the keys issued to your provider —
we store only the hash. A missing, unknown or revoked key is
`401 UNAUTHORIZED`, with no hint as to which.

**Rotation** is generate-new → switch → revoke-old. Both keys work at once
during the switch; nothing is revoked automatically. See
`docs/ssp-provider-onboarding.md` §7.

Do not put the key in a URL, a browser bundle you ship to end users, or a log
line.

---

## 4. The three calls

Set up:

```bash
API=https://v6xg2xoukj.execute-api.us-east-1.amazonaws.com
KEY=sk_...              # your x-savitup-key
CODE=K7M2P9             # what the customer read out
```

Every response is enveloped: `{"ok":true,"data":{…}}` on success,
`{"ok":false,"error":{"code":"…","message":"…"}}` on failure. Read the HTTP
status *and* `error.code`; the message is for humans and may change.

### 4.1 Discover your templates

```bash
curl -s "$API/ssp/share/templates" -H "x-savitup-key: $KEY"
```

```json
{"ok":true,"data":{"templates":[
  {"templateId":"renewal-call",
   "name":"Renewal call",
   "purpose":"Confirm your current cover before your renewal",
   "ttlSeconds":600,
   "fields":{"required":["zip","carrier"],"optional":["premium"]},
   "map":{"zip":"postal_code","carrier":"current_carrier","premium":"premium_monthly"}}
]}}
```

`map` is catalog key → **your** field name. Everything you receive later
(`received`, `missing`, the decrypted object's keys) is in the right-hand
names, so you can ignore SavItUp's catalog keys entirely.

Cache this. It changes only when we change your configuration.

### 4.2 Mint the request

```bash
curl -s -X POST "$API/ssp/share/requests" \
  -H "x-savitup-key: $KEY" -H 'content-type: application/json' \
  -d "{\"userCode\":\"$CODE\",\"templateId\":\"renewal-call\",\"reference\":\"crm-8891\"}"
```

| Field | Required | Notes |
|---|---|---|
| `userCode` | yes | The six characters the customer read out. Case-insensitive — we uppercase and trim before matching. |
| `templateId` | yes | From §4.1. |
| `reference` | no | 1–200 chars, **your** id. Echoed back on the status route and on the delivery, so you can join this to your own record without keeping a map. |
| `ttlSeconds` | no | Integer 60..604800 (1 min .. 7 days). Defaults to the template's own window. This is how long the customer has to answer. |

```json
{"ok":true,"data":{"requestId":"…32 chars…","status":"pending","expiresAt":1788…}}
```

`201`. `expiresAt` is Unix seconds.

**The code is spent at this moment and cannot be reused.** Validation that
does not need the code (auth, rate limit, template, ttl, reference, your
public key) all happens *first*, on purpose — a request refused for a typo'd
template id does not cost the customer their code. Anything that reaches
`404 CODE_INVALID` did consume an attempt, so ask for a fresh code rather than
retrying.

### 4.3 Poll the status

```bash
curl -s "$API/ssp/share/requests/$REQUEST_ID" -H "x-savitup-key: $KEY"
```

```json
{"ok":true,"data":{
  "requestId":"…","reference":"crm-8891","status":"approved","expiresAt":1788…,
  "complete":false,
  "received":["postal_code","current_carrier"],
  "missing":["premium_monthly"]}}
```

Poll no faster than every 2–3 seconds, and stop at `expiresAt` or at a
terminal status. Polling is optional: the delivery (§5) is the authoritative
event and arrives whether or not you poll.

---

## 4b. Constraints — narrowing what a field may contain

Every field in the catalog has **baseline rules**, published as one real
JSON Schema document:

<https://savitup.com/docs/share-fields.schema.json>

A request reaches the customer's phone as a schema, not a list of names, and
**every value is validated against it on their device before anything is
sealed**. You never receive a value that broke a rule; you receive it in
`missing` instead.

Your template may **narrow** those rules — never widen them, never change a
field's type. Ask us to set `constraints` on a template (they appear on
`GET /ssp/share/templates`) and we validate them at save:

```json
{"constraints": {
  "monthlyPremium": {"minimum": 100, "maximum": 5000, "multipleOf": 1,
                     "errorMessage": {"minimum": "Premiums start at $100"}},
  "policyNumber":   {"pattern": "^AB[0-9]{8}$",
                     "errorMessage": {"pattern": "Policy numbers look like AB12345678"}}}}
```

**Allowed keywords, and nothing else:** `type` (`string`, `number`,
`integer`, `boolean`), `enum`, `const`, `pattern`, `minLength`, `maxLength`,
`minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`,
`format`, `title`, `description`, `errorMessage`.

**Allowed `format` values:** `zip`, `phone`, `email`, `date`, `vin`,
`state`.

Anything else — `$ref`, `$id`, `if`/`then`/`else`, `allOf`/`anyOf`/`oneOf`/
`not`, nested `properties`, `dependentRequired` — is refused with
`400 BAD_SCHEMA`. A schema is data you send to somebody's phone, so the
subset is small on purpose.

**The narrowing rule, exactly:**

| Keyword | May be |
|---|---|
| `type` | absent, or the same as the baseline's |
| `enum` / `const` | a subset of the baseline's `enum`, or a new set where the baseline names none |
| `minimum` / `exclusiveMinimum` | at or above the baseline's floor |
| `maximum` / `exclusiveMaximum` | at or below the baseline's ceiling |
| `minLength` | at or above the baseline's |
| `maxLength` | at or below the baseline's |
| `multipleOf` | a multiple of the baseline's, when it sets one |
| `format` | absent, or the same as the baseline's |
| `pattern` | always allowed — an extra pattern is **added**, so both must match |
| `title` / `description` / `errorMessage` | yours; they replace ours |

Widening anything is `400 SCHEMA_WIDENS`.

**`errorMessage`** is the sentence the customer reads when a value fails. One
string covers every keyword, or a map keyword → string covers them one at a
time. Each is at most 120 characters of plain text — no markup, no links.
Where you write none, ours is used, so every field has a sentence either way.

**`pattern` guardrails:** at most 200 characters, must compile as a regular
expression, and no quantifier nested inside a quantified group (`(a+)+`) —
that shape can make a matcher run away, and this one runs on a customer's
phone. A pattern that breaks any of those is `400 BAD_PATTERN`. The phone
also gives each check a 200 ms budget and treats an over-budget check as a
failure.

Constraints are configured by SavItUp on your account — send us the rules
with your template and we will set them.

---

## 4c. Get listed in the app (one-tap apply)

Everything above is the directed flow started by a phone call. **One-tap
apply** is the same flow, minted by the person themselves, from a row in
the app's Apply tab — no code, no call, nothing for you to build. This
section is about that row; the delivery you receive is unchanged (§5).

### Prerequisites

You already have everything this needs if you have done §2–§4:

- An **active** provider account.
- **Share requests enabled**, with a **share public key** on file (§2, §7
  — no plaintext option here either).
- **At least one share template** (§4) — one-tap apply mints from a
  template exactly like a directed request does.
- A **lead mapping and rate card**, same as for any delivery — a listed
  applicant is billed the same way any delivered share is.

Nothing else. There is no separate contract, endpoint, or key for this.

### What you fill in (Nirvaham)

Ask SavItUp to turn on **"Listing in the app"** on your provider (Nirvaham
→ SSP → Providers → your provider → Listing in the app), or send us the
values and we will set it:

| Field | Notes |
|---|---|
| **Display name** | Optional, ≤ 40 characters. Falls back to your provider name. |
| **Tagline** | Optional, ≤ 80 characters, plain text — shown under the name. |
| **States** | Two-letter codes, at least one. The row only shows to a person whose profile state is in this list. |
| **Products** | At least one (Auto, Home, …) — shown as chips on the row. |
| **Share template** | Which of your templates §4 the row mints from. Required. |
| **Logo URL** | Optional, `https://`, ≤ 500 characters. An initials avatar is used when this is absent or fails to load. |

### What the customer sees

A row in the app's Apply tab, under "Apply with a local agent or carrier":
your logo (or initials), display name, tagline, and product chips. They
tap it, see the **same consent screen** a directed request from you would
show, and approve with their PIN. Nothing about the screen, the fields
asked for, or the approval flow differs from a phone-call-started request.

### The delivery you get

**Byte-identical to a directed request minted for the same template.**
The row that gets built and delivered is produced by the same code path
(`buildDirectedRow`) that a phone-call directed mint uses, from the same
template, the same field mapping (§5), the same sealed payload. Your
delivery endpoint cannot tell a one-tap apply from a phone-call directed
request — the POST body it receives is the same shape either way.

`GET /ssp/share/requests/{requestId}` (§6) answers for one-tap requests exactly
as for your own directed ones; you learn the `requestId` from the delivery
POST body.

The one difference lives only inside SavItUp: the stored request row (and
Nirvaham's SSP → Shares list) carries `appliedVia: "app"` and shows an
**"App"** tag next to it, so support can see the customer started this
themselves. **This field is not sent to your endpoint** — it is not part
of the delivered envelope, only a provenance note visible to us.

No user code is spent and no push notification is sent for a one-tap
apply — the person is already looking at their phone.

### Rate limits

**5 applies per user per minute**, on top of every other limit in §8 —
the app itself never lets this be hit under normal use; it exists to cap
abuse, not to shape integration.

---

## 5. The delivery POST your endpoint receives

When the customer approves, your endpoint gets:

```http
POST /your/intake HTTP/1.1
Content-Type: application/json
Idempotency-Key: <shareId>
x-api-key: <your endpoint secret>      # or your chosen Authorization header
```

```json
{
  "kind": "share",
  "shareId": "…",
  "requestId": "…",
  "reference": "crm-8891",
  "templateId": "renewal-call",
  "complete": false,
  "received": ["postal_code", "current_carrier"],
  "missing": ["premium_monthly"],
  "payload": { "enc": "…", "iv": "…", "ciphertext": "…", "tag": "…", "aad": "…" }
}
```

- `kind` is always `"share"` for this flow. (Ordinary lead deliveries on the
  same endpoint have no `kind` — that is how you tell them apart.)
- `reference` is present only if you sent one.
- `missing` can only ever contain **optional** fields: the app refuses an
  approval that drops a required one.
- `payload` is sealed to your public key. §7.

### What we do with your answer

| Your status | Meaning |
|---|---|
| `2xx` | Accepted. Done. (If the body is JSON with a top-level `id` or `ref` string, ≤128 chars, we keep it as `providerRef`.) |
| `4xx` | **Permanent** failure — never retried. A 4xx says "this specific payload is wrong", and resending it unchanged cannot help. |
| `5xx`, timeout, no response | **Transient** — retried. |

- **Timeout**: 10 s by default (configurable per provider, 1–30 s). Slower is
  a transient failure.
- **Retries**: up to **5 attempts total**, roughly **4 minutes apart** (a flat
  SQS visibility timeout, not escalating backoff) — about 16 minutes end to
  end. After the fifth, the share is `failed` permanently and the message goes
  to a dead-letter queue for a human, never silently dropped.
- **Idempotency**: every attempt carries `Idempotency-Key: <shareId>`, the
  same key across retries. **You must dedupe on it.** Our pipeline can
  legitimately re-send a share it already delivered — e.g. if our Lambda dies
  after your 200 but before it records the fact. That header is how you tell a
  redelivery from a new share.

Return 200 quickly and do your own work asynchronously. Do not hold the
connection open while you decrypt, write to a CRM and send an email.

---

## 6. Status semantics

| `status` | Terminal | Meaning |
|---|---|---|
| `pending` | no | Minted. The push has gone out; the customer has not opened it. |
| `claimed` | no | It is on their screen right now. |
| `approved` | **yes** | They approved. A delivery is on its way (or has already landed). `complete`/`received`/`missing` are populated. |
| `declined` | **yes** | They said no. Nothing is coming — **do not re-mint on the same call.** Nothing about the decline is recorded beyond the fact of it: no fields, no reason. |
| `expired` | **yes** | The window closed unanswered. Ask for a fresh code and mint again. |

`expired` is enforced on read, not by a sweeper — a lapsed request reports
`expired` the next time anything looks at it.

`approved` does not mean *delivered*. Delivery is tracked separately (and
retried, §5); if you 5xx five times the share stays `approved` here while your
integration is `failed` on our side. `docs/ssp-provider-onboarding.md` §9 is
the recovery path.

---

## 7. Decrypting the payload

The values are encrypted **on the customer's phone**, to your key. SavItUp
holds no key that can open them.

```
enc    = 0x04 || Xe || Ye              (the phone's ephemeral P-256 public key)
shared = ECDH-P256(ephemeral, yours).x, 32 bytes big-endian
salt   = SHA-256(enc || yourPublicKeyRaw)
info   = "savitup/share-request/v1"
key    = HKDF-SHA256(shared, salt, info, 32)
plain  = AES-256-GCM-decrypt(key, iv, ciphertext || tag, aad)
aad    = "<requestId>|directed|<catalogVersion>"
```

The AAD is authenticated, not encrypted — pass it through **exactly as
delivered**; never parse it, never rebuild it. It binds this ciphertext to
this request, so a payload from one request can never be replayed into
another.

`plain` is a JSON object keyed by **your** field names:

```json
{"postal_code": "80301", "current_carrier": "Acme", "premium_monthly": 142}
```

A copy-pasteable, dependency-free Node 18+ implementation lives in
**`docs/ssp-share-decrypt.md`** (<https://savitup.com/docs/share-decrypt.md>) — that snippet is executed by our own test
suite against published vectors on every commit, so it is not aspirational.

Two rules:

1. **If `decrypt` throws, do not fall back to anything.** A failure means the
   ciphertext, the tag or the AAD was altered in transit. There is no partial
   answer worth having.
2. **Your private key never leaves your side.** Generate the pair yourself;
   send us only the public half.

The test vectors in `shared/share-crypto-vectors.json` include
`directed-mapped` — mapped field names, the `|directed|` AAD. Run your
implementation against all four before you go live. **The private keys in that
file are published TEST keys. Never use one anywhere real.**

---

## 8. Limits

| Limit | Value |
|---|---|
| Customer code | 6 characters, **10 minutes**, **single use**, at most 5 live per customer at a time |
| `ttlSeconds` | integer **60 .. 604800** (1 minute to 7 days) |
| `reference` | 1 .. 200 characters |
| `templateId` | `[a-z0-9-]{3,32}` |
| Mint rate | **60 per minute** per account → `429 RATE_LIMITED` |
| Delivery timeout | 10 s default, 1–30 s configurable |
| Delivery attempts | 5, ~4 minutes apart |

---

## 9. Errors

| Status + code | Cause | What to do |
|---|---|---|
| `401 UNAUTHORIZED` | Missing, unknown or revoked `x-savitup-key`. | Check the header name and the key. |
| `404 CODE_INVALID` | The code was mistyped, already spent, or older than ten minutes. **All three are the same answer on purpose** — we will not tell a guesser which. | Ask the customer for a new code. |
| `404 TEMPLATE_UNKNOWN` | No template with that id on your account (a malformed id answers the same). | `GET /ssp/share/templates`. |
| `400 BAD_TTL` | `ttlSeconds` outside 60..604800, or not an integer. | Send an integer in range, or omit it. |
| `400 BAD_SCHEMA` | A constraint used a keyword outside the allowlist, or an `errorMessage` that is too long or not plain text. | See §4b. |
| `400 SCHEMA_WIDENS` | A constraint tried to loosen a baseline rule or change a field's type. | Narrow, don't widen (§4b). |
| `400 BAD_PATTERN` | A `pattern` over 200 characters, uncompilable, or with a nested quantifier. | Simplify the pattern (§4b). |
| `400 BAD_REFERENCE` | `reference` empty or over 200 chars. | Fix or omit it. |
| `400 BAD_JSON` | The body did not parse. | Send JSON, with `content-type: application/json`. |
| `409 KEY_REQUIRED` | No share public key registered for you. **There is no plaintext option in this flow.** | Send us your public key. |
| `403 SHARE_REQUESTS_DISABLED` | Your account is inactive, or share requests are not enabled on it. | Talk to us. |
| `429 RATE_LIMITED` | Over 60 mints a minute. | Back off a minute; this is not fatal. |
| `404 NOT_FOUND` (status route) | Unknown `requestId` — **or somebody else's**. We do not distinguish. | Check the id you stored. |
| `500 MALFORMED_SHARE` | A request whose field status cannot be computed. | Report it to us with the `requestId`. This is our bug, not yours. |

Note the deliberate absence of oracles: an id that is not yours and an id that
does not exist answer identically, and so do the three ways a code can be
dead. Do not build logic that tries to tell them apart.

---

## 10. Validate with the sandbox

Before you write a line of your own endpoint, you can watch the whole flow
happen against an endpoint SavItUp runs for you.

**What it is:** `SavItUpSandbox` — a single Lambda behind a public Function
URL (`backend/src/handlers/sandboxDelivery.ts`,
`backend/cdk/lib/sandbox-stack.ts`). It accepts exactly the delivery POST in
§5, checks the auth header, and writes **one JSON line to CloudWatch** per
delivery:

```json
{"kind":"share","shareId":"…","requestId":"…","reference":"crm-8891",
 "templateId":"renewal-call","complete":false,
 "received":["postal_code","current_carrier"],"missing":["premium_monthly"],
 "payloadBytes":412,"decryptedFields":["current_carrier","postal_code"]}
```

`decryptedFields` appears only when a share private key is configured, and it
is **names, sorted, and nothing else**. The sandbox never logs a field value,
in any mode — there is no flag for that. If the payload does not open, the
line carries `decryptError` with the error's name instead, and still answers
200: the *delivery* was accepted; retrying it would not change the outcome.

**Standing it up** (SavItUp operator, once per environment):

```bash
# 1. Deploy. Both stacks go together; a bare `cdk deploy` refuses to guess.
cd backend && AWS_PROFILE=savitup npx cdk deploy --all --require-approval never

# 2. Generate the key pair + endpoint secret, straight into SSM.
scripts/sandbox-keys.sh --profile savitup
```

That script writes two **SecureString** parameters and prints what you paste
into Nirvaham:

| Parameter | Contents |
|---|---|
| `/savitup/sandbox/endpoint-secret` | The value the sandbox demands in `x-api-key`. |
| `/savitup/sandbox/share-private-key` | The PKCS#8 base64 private key. **Never printed, never written to a file.** |

It prints the **raw uncompressed base64 public point** (exactly the format
Nirvaham's *Share public key* field validates) and the endpoint secret. Neither
parameter is created by CloudFormation, because CloudFormation cannot create a
SecureString at all — until the script has run, the sandbox has no secret to
compare against and answers `401` to everything, which is the correct posture
for a public URL.

Then, in Nirvaham → SSP → Providers, point a **test provider** at the stack's
`SandboxDeliveryUrl` output with auth header `x-api-key`, that endpoint secret
and that public key, enable share requests, and add a template — or let
`scripts/sandbox-ssp.sh --profile savitup --env dev` write exactly those
records (it prints the inbound key once). Mint against it from
<https://savitup.com/business> and watch:

**Operator note — what a share-only provider still needs.** The provider
record is shared with the lead pipeline, so Nirvaham insists on three things
that have nothing to do with directed sharing: a `carrierId`, a non-empty
lead field mapping, and a **rate card in effect before the provider can be
set active** (activation is refused with `NO_RATE_CARD`; price the `share`
tier at whatever the deal says and the rest at 0). A directed mint against
an inactive provider answers `403`, so the order is: create (inactive) →
rate card → activate → share public key + templates → inbound key.

```bash
aws logs tail /aws/lambda/savitup-sandbox-delivery-dev --follow --profile savitup
```

Re-running `sandbox-keys.sh` **rotates both values**: the old endpoint secret
stops working within the handler's 5-minute parameter cache, and any share
already sealed to the old public key can never be opened again.

---

## 11. Go-live checklist

- [ ] Public key generated **by you**, private half stored in your own secret
      manager; fingerprint read back to SavItUp and confirmed.
- [ ] Your decrypt implementation opens all four vectors in
      `shared/share-crypto-vectors.json`, including `directed-mapped`.
- [ ] Your decrypt path **throws and stops** on a tampered AAD — no fallback.
- [ ] Endpoint is `https://`, returns 200 in well under 10 s, and does its
      real work asynchronously.
- [ ] Endpoint **dedupes on `Idempotency-Key`** and you have tested a
      duplicate POST of the same `shareId`.
- [ ] Endpoint returns 4xx only for genuinely unprocessable bodies (it is
      never retried), 5xx for anything transient.
- [ ] You store `requestId` against your own record at mint time, and/or send
      a `reference`.
- [ ] You handle every terminal status, including `declined` — and do **not**
      re-mint on the same call after one.
- [ ] You back off on `429` rather than treating it as fatal.
- [ ] Your key, your endpoint secret and any decrypted values are absent from
      your own logs.
- [ ] End-to-end rehearsal done against the sandbox (§10) before your real
      endpoint is registered.
- [ ] Key-rotation runbook written on your side (generate → switch → revoke).

---

Questions: <support@savitup.com>.
