Key Security

A Flint integration holds two kinds of long-lived secrets: API keys (flint_test_... and flint_live_...) and webhook signing secrets (whsec_...). Either one, exposed, hands a stranger a piece of your payments stack. This guide is the operating manual for those secrets: where they belong, how to cap the damage a leak can do before it happens, and the exact playbook for the day one gets out. For how authentication itself works (headers, test vs live keys, error codes), see Authentication.

Every Flint Key Is a Secret Key#

Some payment APIs split credentials into a publishable half for the browser and a secret half for the server. Flint does not. There is no publishable key: every flint_ key is the secret half, authenticates as your merchant, and exercises every scope it holds. A flint_live_ key does all of that with real money.

Never put a key in client code

Anything you ship to a browser, mobile app, or desktop client is public, no matter how it is obfuscated. A key in frontend JavaScript, a mobile binary, or a public repo is a key you have already lost. Keys belong in server-side code and server-side configuration only.

The natural objection is "but my checkout runs in the browser". Buyer-facing surfaces never need your key. Hosted and embedded checkout authenticate the buyer with a short-lived session credential that your server mints, scoped to that one checkout and useless after it completes. Your key stays behind your API; the buyer's browser only ever holds the session credential. See Checkout Sessions and Embedded Payments for how those flows keep the key server-side.

Just as important is knowing which values are not secret. Every Flint credential splits into a safe identifier and secret material:

identifiers

Safe to log and display

The api_key_id (key_...), the key_prefix (flint_test_1a2b3c4d), and webhook endpoint ids (whep_...). Use these in logs, dashboards, alerts, and support requests to name a credential without exposing it.

credentials

Secret

The full secret_key (flint_test_..., flint_live_...) and a webhook endpoint's secret (whsec_...). These never belong in logs, error messages, analytics events, tickets, screenshots, or LLM prompts.

Flint stores only an irreversible hash of each key, which is why the full secret_key appears exactly once, in the response that creates it. That is a feature, not a limitation: nothing that reads Flint's records can recover your key, and neither can Flint support. If you lose a key, you do not recover it; you rotate it.

Keep Secrets Out of Code and Source Control#

Code gets copied, forked, reviewed, pasted into tickets, and fed to tools. Configuration does not have to be. Load keys from the environment or a secrets manager (AWS Secrets Manager, Vault, Doppler, your platform's equivalent), and keep the values out of the repository entirely:

Bash
# .env (never committed)
FLINT_API_KEY=flint_live_...
FLINT_WEBHOOK_SECRET=whsec_...
Bash
# .gitignore
.env
.env.*

In CI, use the provider's secret store rather than checked-in config, and make sure the values are masked in job logs. A key that prints in a public build log is just as leaked as one in a commit.

Frontend env vars are not env vars

Frontend bundlers compile any variable with a public prefix (VITE_, NEXT_PUBLIC_, REACT_APP_) into the shipped JavaScript, where anyone can read it. A Flint key must never carry one of those prefixes. If your frontend needs data that only your key can fetch, add a server endpoint that fetches it; do not move the key to the client.

Git history does not forget

Deleting a committed key in a follow-up commit does not unleak it. The key survives in history, clones, forks, and CI caches, and secret-scanning bots watch public repos for exactly this. Treat any key that has ever appeared in a commit as leaked, even in a private repo: run the leak playbook rather than rewriting history and hoping.

Add secret scanning to CI and pre-commit hooks. Flint credentials are built to be scanned for: every key starts with flint_test_ or flint_live_, and every webhook secret with whsec_, so a scanner rule is a simple prefix match. Catching a key in review costs a minute; catching it in production costs the rest of this guide.

Scope Keys to the Job#

A key's scopes are its blast radius. The habit that contains a leak before it happens is one key per workload, named after where it runs, holding only the scopes that job needs:

  • One key per service or integration. "Storefront backend", "Reporting worker", "CI runner". When one leaks you revoke it without touching the others, and each key's last_used_at tells a clean story about a single workload.
  • Fewest scopes that do the job. A .write scope also satisfies the matching .read, so granting commerce.orders.write covers both; you never need to grant the pair.
  • Keys cannot escalate. Creating or updating a key over the API can only grant scopes the calling key itself holds. A leaked read-only key cannot mint itself a broader one.

A reporting service that only reads orders and payments needs exactly that and nothing more:

Bash
curl -X POST https://api.withflintpay.com/v1/api-keys \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer flint_test_YOUR_KEY" \
  -d '{
    "name": "Reporting worker",
    "scopes": ["commerce.orders.read", "payments.payment_intents.read"],
    "sandbox_id": "test_01KNB061AGD0CYYF16M5QAQE3N"
  }'

If this key leaks, the attacker can read order and payment data, which is bad. They cannot create refunds, touch payouts, or issue new keys, which is much worse. Least privilege does not prevent the incident; it caps its cost.

API key scope catalog

Every scope and the operations it grants.

Expire Keys That Should Not Live Forever#

Some keys have a natural end date: a contractor's engagement, a one-off migration script, a load test, a demo. Set expires_at when you create the key and it shuts itself off on schedule, whether or not anyone remembers it exists:

Bash
curl -X POST https://api.withflintpay.com/v1/api-keys \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer flint_test_YOUR_KEY" \
  -d '{
    "name": "Catalog migration (temporary)",
    "scopes": ["commerce.products.write"],
    "sandbox_id": "test_01KNB061AGD0CYYF16M5QAQE3N",
    "expires_at": "2026-08-01T00:00:00Z"
  }'
  • expires_at is an RFC 3339 timestamp and must be in the future. Omit it and the key never expires.
  • PATCH /v1/api-keys/{api_key_id} can set or move the date on an existing key, or clear it with "expires_at": null, so you can extend access without minting a new key.
  • Once the moment passes, the key fails closed: every request returns 401 API_KEY_EXPIRED. Issue a new key if the work genuinely is not done.

An expiring key is the cheapest security control Flint offers: nothing to remember, nothing to clean up, and the failure mode is a loud 401 instead of a forgotten credential with production access.

Rotate Keys Without Downtime#

Rotation puts an upper bound on how long a stolen-but-undetected key stays useful. There is deliberately no rotate endpoint for API keys; rotation is create, deploy, revoke, and because old and new keys both authenticate during the overlap, traffic never drops:

1

Create the replacement#

Create a new key with the same scopes (and, for test keys, the same sandbox_id) as the one you are retiring. Capture the secret_key from the response; it is shown exactly once.

2

Deploy it#

Update the value in your secrets manager and roll your servers. Both keys work during the rollout, so there is no downtime window to schedule.

3

Confirm the old key has drained#

Watch the old key's last_used_at, which updates about once a minute while a key is in use. Once it stops advancing, nothing is calling with it anymore.

4

Revoke the old key#

Call POST /v1/api-keys/{api_key_id}/revoke. Revocation is immediate and permanent. Anything still holding the old key starts failing with 401 API_KEY_REVOKED, which shows up in your logs as a traceable straggler rather than a silent gap.

Rotate on events, not just on a calendar: whenever someone with access to production secrets leaves, and after any security incident at a vendor that held the key (your CI provider, log aggregator, or host). A fixed cadence on top of that, quarterly is common, keeps the muscle exercised so rotation day is routine instead of an emergency drill.

last_used_at also makes a periodic key audit cheap. List your keys and read the timestamps:

Bash
curl "https://api.withflintpay.com/v1/api-keys" \
  -H "Authorization: Bearer YOUR_API_KEY"
JSON
{
  "data": [
    {
      "api_key_id": "key_01KWJ93G11C7MF8REX91MDS0CD",
      "name": "Storefront backend",
      "key_prefix": "flint_test_1a2b3c4d",
      "scopes": ["commerce.orders.write", "payments.payment_intents.write"],
      "status": "active",
      "last_used_at": "2026-07-03T09:14:07Z",
      "created_at": "2026-04-18T12:00:00Z"
    },
    {
      "api_key_id": "key_01KWK4Q8Z1MB7F0T2S9R3V6HAX",
      "name": "Load test (March)",
      "key_prefix": "flint_test_9f8e7d6c",
      "scopes": ["commerce.orders.write"],
      "status": "active",
      "last_used_at": "2026-03-30T16:41:22Z",
      "created_at": "2026-03-12T08:30:00Z"
    }
  ],
  "request_id": "2f8c1d9a-4b7e-4c53-8d30-a9f2c5e7b481"
}

The second key tells its own story: created for a March load test, unused since. A key nobody uses is pure liability, so revoke it:

Bash
curl -X POST https://api.withflintpay.com/v1/api-keys/key_01KWK4Q8Z1MB7F0T2S9R3V6HAX/revoke \
  -H "Authorization: Bearer flint_test_YOUR_KEY"

Webhook Signing Secrets#

Webhook signing secrets deserve the same handling as keys, because a leaked whsec_ enables a quieter attack: it does not let anyone call your API, but it lets them forge events to your webhook handler. A convincing fake order.payment_succeeded aimed at a fulfillment handler ships product that was never paid for. Store whsec_ values exactly like keys, and rotate them with the same urgency.

Each webhook endpoint has its own secret, per endpoint and per mode, returned once when the endpoint is created. Two things work differently from API keys:

POST/v1/webhook-endpoints/{webhook_endpoint_id}/rotate-secret

Unlike API keys, webhook secrets have a dedicated rotation endpoint. It returns a new secret once, and for the next 24 hours deliveries validate against either the old or the new secret, so you can update your config without dropping events. Requires webhooks.webhooks.write.

And secrets are mode-specific: sandbox endpoints get sandbox secrets, live endpoints get live secrets. Carrying a sandbox whsec_ into production config is the classic go-live webhook failure; every live event fails verification until you swap in the live endpoint's secret. Signature verification itself, raw-body handling, and the rotation overlap in practice are covered in Webhooks.

If a Key Leaks#

A key committed to a repo, pasted into a ticket or chat, printed in a build log, or one you simply cannot account for: run this playbook. Speed beats certainty. Your integration erroring for a few minutes is recoverable; an attacker holding a live key is not. If you are unsure whether a key actually leaked, revoke it anyway and let the playbook answer the question.

1

Revoke it immediately#

Use the dashboard's API keys page or the API:

Bash
curl -X POST https://api.withflintpay.com/v1/api-keys/key_01KWJ93G11C7MF8REX91MDS0CD/revoke \
  -H "Authorization: Bearer flint_live_YOUR_KEY"

Revocation takes effect immediately and is permanent. Do not wait for a maintenance window: revoke first, fix your deployment second.

2

Deploy a replacement#

Create a new key with the same scopes (or fewer; a leak is a good moment to trim) and roll it out through your secrets manager, the same motion as a routine rotation.

3

Audit the exposure window#

Start with the revoked key's last_used_at. If it never advanced beyond your own traffic, you probably got there first. Either way, review activity between the leak and the revocation: orders, refunds, payouts, and customer reads you did not initiate. If the key held accounts.api_keys.write or webhooks.webhooks.write, also list your API keys and webhook endpoints. New keys are an attacker's persistence (revoking the leaked key does not revoke keys it created) and a new webhook endpoint streams your event data to them. Revoke and delete anything you did not create.

4

Rotate everything that traveled with it#

Keys rarely leak alone. If a .env file, config bundle, or CI log leaked, every secret in it is out: other Flint keys, whsec_ secrets, and third-party credentials. Rotate them all, not just the one you noticed.

5

Escalate if money moved#

If you find payments, refunds, or payout changes you did not make, contact Flint support with the affected request_id values (also in each response's x-request-id header) so the activity can be traced precisely.

After revoking, watch your logs for 401 API_KEY_REVOKED. Each one is either your own straggler still holding the old key (a deploy to fix) or a continued attempt with the stolen one (confirmation you revoked the right key).

Key Security Checklist#

  • keys live in server-side env vars or a secrets manager, never in code, client bundles, or commits
  • .env files are gitignored, and secret scanning watches for flint_test_, flint_live_, and whsec_ prefixes
  • CI uses the provider's secret store and masks secrets in job logs
  • one key per service, named for where it runs, holding only the scopes that job needs
  • temporary access gets expires_at, not a calendar reminder
  • rotation has an owner and a cadence, and also runs on offboarding and vendor incidents
  • keys with a stale last_used_at get revoked, not kept just in case
  • whsec_ secrets are handled like keys and rotated with the same urgency
  • everyone on call knows the revoke path before they need it

Next Steps#

Rate this doc