Skip to main content
A reference for partners integrating with the Frayme fraud-analysis platform.
Today, API keys, the workflow IDs you may submit cases to, and webhook configuration (URL, signing secret) are all provisioned for you by the Frayme team. In the future, both API keys and webhook configuration will be self-served from the Frayme portal.
This guide is the narrative walkthrough. For the field-level reference of each endpoint, see the API reference; for the events Frayme pushes to you, see Webhooks.

Concepts

You submit cases (KYC, KYB, or Transaction) to Frayme. Each case is routed through a tenant-defined workflow that produces a decision (approved, declined, or in_review). You can:
  • Submit cases via the Case API.
  • Poll case status via the Case API.
  • Receive webhooks when a decision is reached (or when an analyst later overrides it).

Authentication

All Case API calls require an API key scoped to your tenant. The key is sent on every request using the X-API-Key header:
No other authentication mechanism is supported on the partner endpoints. Each key carries one or more scopes:

Submitting a case

POST /cases — requires scope cases:write.

Request body

Do not send tenantId — your tenant is derived from the API key, and any tenantId in the body is ignored.
string
required
Provisioned for you by Frayme.
integer
Pin the version your client expects. If omitted, the workflow’s current published version is used.
string
required
One of KYC, KYB, or Transaction.
object
required
Validated against the workflow’s input schema. Fields and types are dictated by the workflow you target; submitting a payload that does not match the schema returns 400.
object
Free-form audit fields; surfaced in the portal and event log.
object
required
Typed business attributes used for entity resolution and review. displayName is required on every subject and is the label shown in review queues. Then set exactly one sub-struct, and it must match type: transaction for Transaction, person for KYC, business for KYB. Setting none, more than one, or the wrong one returns 400. See Subject sub-structs for the per-type required fields.
string
If provided and a case with the same key already exists, the existing case is returned with 200 OK instead of 201 Created.
string
RFC 3339 timestamp of the originating business event (e.g. 2026-05-19T14:32:00Z). Required for Transaction cases, optional otherwise. A value that doesn’t parse, or one too far in the future, returns 400.

Subject sub-structs

displayName is required on every subject. Then set the one sub-struct matching type. Each sub-struct carries one or more identifiers that drive entity resolution.

Identifiers

An identifier is { "type": <string>, "value": <string>, "country": <string, optional> }. type must be one of the closed enum below (exact lowercase match): Identifiers are classed strong (resolve identity: cpf, cnpj, passport, national_id, company_registration, tax_id, drivers_license, external_customer_id, wallet_address, pix_key) or weak (attributes only: email, phone, bank_account). A submission that doesn’t meet the per-type identifier requirements below returns 400.

Bank account formats

A bank_account value starts with a scheme, and the scheme determines the country:
  • Brazil accepts either IBAN or BRISP. For BRISP, the account check digit is required after a hyphen; the branch check digit is optional.
  • Other countries cannot send a bank_account yet.
  • IBAN check digits (mod-97), the US routing-number checksum and the CLABE check digit are validated. An invalid value returns 400.
  • bank_account is weak. It never identifies a party on its own, so a transaction party (sender or receiver) whose only identifiers are weak returns 400. Send a strong identifier with it, such as cpf, cnpj, pix_key or external_customer_id.

Transaction → transaction

Each party is { "role": "sender" | "receiver", "displayName": <string, optional>, "identifiers": [...] }. Both parties need at least one strong identifier; a party carrying only weak identifiers returns 400. The customer-side party (the sender when outbound, the receiver when inbound) must also have a non-empty displayName and an external_customer_id.

KYC → person

KYB → business

Response

Polling case status

GET /cases/{caseId} — requires scope cases:read.

Response

Status values

The case status reflects only lifecycle, not the outcome — read result.decision.value for the outcome. Notes:
  • result is absent until the workflow completes.
  • result.decision.value is one of approved, declined, or in_review.
  • result.decision is always the current decision; result.decisionHistory[0] is the workflow’s initial decision, and later entries are analyst overrides.
  • result.decision.source is one of workflow, risk_evaluation, or analyst. risk_evaluation means the case was decided by the pre-DAG risk gate before the workflow ran; analyst means a human overrode the decision.
  • Each decision entry (current and historical) may also carry optional fields: declineReason, queueName, riskScore, and notes. They are present only when set.
  • result.workflow_result is write-once: analyst overrides never mutate it.
  • result.riskEvaluation is the audit record of the pre-DAG risk gate (when it ran): status (ok / failed_closed), action (deny / review / workflow), highestSeverity (low / medium / high / critical), and a list of triggeredRules (each with id, name, severity, conditions, ruleVersion). Treat it as read-only audit evidence.
  • result.dataSources (when present) lists the enrichment providers the workflow called, each with nodeId, providerId, and status.
Poll on a backoff (for example, 1s, 2s, 5s, then every 10s) until status is completed, then read result.decision.value. Prefer webhooks over long polling whenever possible.

Webhooks

Frayme delivers signed HTTPS POSTs to a URL configured for you. The dedicated Webhooks page is the canonical reference for every event, payload shape, and signature-verification detail.

Events you may receive

Payload shapes

Sent for case.decided, case.pending_review, and case.decision_overridden.

Deduplication via webhookId

Every outbound webhook (decision events and dispatch events alike) carries a unique webhookId (UUID). Frayme’s delivery is at-least-once — a transient failure or network blip can produce duplicate deliveries with the same webhookId. Persist the webhookId of every event you successfully process and reject (or short-circuit) any event whose webhookId you’ve seen before.

Headers

Verifying the signature

  1. Compute HMAC over the exact bytes of the request body — do not re-serialize the JSON.
  2. Use a constant-time comparison (for example, hmac.compare_digest).
  3. If X-Frayme-Secret-ID is present, pick the matching secret from your rotation set.
  4. Respond 2xx within ~10 seconds (the per-delivery timeout). Non-2xx responses and timeouts are retried with exponential backoff — 1s, 2s, 4s, 8s, 16s — up to the configured retry limit (5 attempts by default); final failures are recorded but not retried indefinitely.
  5. Treat deliveries as at-least-once; deduplicate by webhookId as described above.

Delivering an external callback

Used only if your workflow has a node that pauses awaiting an external system. After receiving an external_callback.dispatch webhook, the external system posts the result to the one-time URL from the dispatch payload: POST /cases/{caseId}/callbacks/{callbackRef} — requires scope cases:callback.
  • Content-Type: application/json; the body is a JSON object the workflow node consumes.

Answering an information request

An information request asks a case’s customer for more information. One is raised by an analyst in the Frayme console or by a workflow node — raising one is not yet part of the published Case API — and it publishes a case.rfi_requested webhook to your endpoint; your systems send the actual email, Frayme never does. That webhook carries the rfi_id you need below. A case with a request outstanding also reports openRfiCount: 1 on GET /cases/{caseId}, and only one request may be open per case at a time. When the case reaches a terminal decision, any pending request is cancelled silently — you receive the case.decided webhook, never a cancel event.
The case.rfi_requested webhook carries customer PII — to, cc, subject, and body. Keep it out of your logs, including on redelivery, and see the webhook PII warning.
When your customer replies, your backend finishes the request in two steps: upload each document into a presigned slot, then submit the answer. Both routes require scope cases:write, and the request must still be pending.
1

Mint an upload slot, once per file

POST /cases/{caseId}/rfi/{rfiId}/documents
201 Created returns { "uploadId", "uploadUrl", "expiresAt" }. Accepted types are application/pdf, image/jpeg, and image/png, at 1 byte – 20 MB.Every slot you mint counts against the budget for good, even one you never upload to or that expires: 30 per request, 100 per case (a response attaches at most 10 documents). Exceeding it is 422 too_many_documents. A request that can no longer take documents is 409 — already_answered, not_pending, case_terminal, or dry_run.
2

PUT the bytes

PUT the file to uploadUrl before expiresAt, with exactly the Content-Type and Content-Length you declared and the header If-None-Match: * — all three are pinned into the signature and the upload is rejected otherwise.
The URL is create-only: once the object exists a second PUT returns 412 Precondition Failed, so an upload cannot be replaced once it has landed. expiresAt also bounds step 3 — a slot not attached by then is refused, and you mint a fresh one.
Frayme does not inspect file content. The Content-Type you declare is pinned into the signature and checked against the stored object’s header, but the bytes are never sniffed, validated, or scanned for malware. Scan files you collect from end customers before uploading them — an attached document is shown to analysts and may be read by an AI workflow node.
3

Submit the answer, once

POST /cases/{caseId}/rfi/{rfiId}/response
200 OK returns { "rfiId", "status": "answered", "answeredAt", "fieldCount", "documentCount" }.
fields is an ordered array of scalar answers (string, number, boolean, null). Each entry’s key is an identifier (^[A-Za-z_][A-Za-z0-9_]*$) and must be unique across the array — a repeat is 422 invalid_field_key, never a silent last-one-wins. Limits: ≤ 100 entries, ≤ 4 KB per value, ≤ 16 KB in total. At least one field or document is required. The console shows the answers in the order you send them. Each entry may carry an optional label, a human display name for the console (≤ 256 bytes, counted against the same 16 KB total). Labels are display-only — they are never bound into the workflow, so rules keep matching on the key. Every uploadId must have been minted for this request and its object must already be in the bucket with the declared size and type — a minted-but-never-uploaded slot fails the whole submission, and nothing is recorded partially.
A request is answered once — there is no amend and no re-open; a follow-up is a new request. answeredBy is apikey:<keyId> when a key answered, so a retry after a lost response recognises its own answer, or analyst when an analyst recorded the reply in the console.
A successful answer publishes case.rfi_answered and, when the request was raised by an awaiting Information request workflow node, resumes the workflow with the answers available to its rules.

Tagging a case

Tags are short, non-PII operator labels used to organise and triage cases (for example high-risk, review-later) — the same labels analysts apply in the Frayme console. Your tenant is taken from the API key, so tag paths are not tenant-scoped in the URL. Tags applied via the API must come from your tenant’s managed tag vocabulary, configured under Settings → Tags in the portal. An arbitrary label is rejected, so the console’s chips, colours, and filters stay consistent. Add a tag — POST /cases/{caseId}/tags, requires scope tags:write.
Both success responses return the stored tag as { "tag", "createdBy", "createdAt" }. The applier is recorded as your API key (createdBy: "apikey:<keyId>"); any createdBy in the request body is ignored. List a case’s tags — GET /cases/{caseId}/tags, requires scope tags:read. Returns { "tags": [ { "tag", "createdBy", "createdAt" } ] }, oldest-first (an empty array for an untagged case). Remove a tag — DELETE /cases/{caseId}/tags?tag=<label>, requires scope tags:write. Idempotent: removing an absent tag still returns 204 No Content.

Pending actions on a paused node

Some workflow nodes pause to await an external review — a Sumsub identity check, for example — and while paused they expose named actions you can invoke. Unlike an external callback (which resolves the node and resumes the workflow), a pending action is a side effect on the paused node: it returns data and leaves the node paused. Typical uses are re-minting an expiring access token or verification link, or pushing applicant data to the provider before the end user starts.

Handles

Each paused node is addressed by an opaque pending_handle:
  • It is per-paused-node and stable across retries — the same paused node always resolves to the same handle.
  • It is tenant-scoped: a handle from another tenant resolves to 404 (handle existence is never revealed across tenants).
  • It expires with the node’s await window (expiresAt); after that, invoking it returns 404.
You obtain a handle in one of two ways:
  • From a node.notification webhook — the output.pending_handle field (see Webhooks).
  • By listing the case’s paused nodes (below).

List paused nodes

GET /cases/{caseId}/pending — requires scope cases:read.
availableActions is provider-specific — it lists exactly the ids you may invoke on that node. resolvesAwait is false for every action available today (all are side effects).

Invoke an action

POST /pending/{handle}/actions/{actionId} — requires scope cases:callback.
The body is { "params": { ... } }; an empty body is valid for actions that take no parameters. The response wraps the action’s output:
The available actions and their params depend on the node’s provider. For Sumsub, see Data sources → Sumsub → Real-time actions on a paused node.

Durable case actions

A pending action only exists while a node is paused — once the case is decided, the handle is gone. Some provider operations, though, stay meaningful long after the case has finished. The clearest example is re-issuing a Sumsub share token so a partner client can access the applicant months later. For those, Frayme exposes durable case actions, addressed by caseId (no handle) and scoped to the broker that owns them — so they work from the moment the node ran, through the decision, and indefinitely afterward.
Durable case actions are addressed by (brokerId, action) so action ids never collide across brokers. The applicant is re-resolved from the case’s external_customer_id — so the case must have been submitted with one (it is required on every case type).

List durable actions

GET /cases/{caseId}/actions — requires scope cases:read. Lists the durable actions invocable on the case, derived from the brokers it actually ran:

Invoke a durable action

POST /cases/{caseId}/actions/{brokerId}/{action} — requires scope cases:callback.
The response wraps the action’s output, exactly like a pending action:
Each invocation is an independent vendor call — there is no idempotency, so a retry mints a new artifact. Use the returned value rather than calling again “to be safe”.
For Sumsub, see Data sources → Sumsub → Regenerate a share token.

Quick reference

All requests authenticate via the X-API-Key header. The base URL of the Case API and your webhook configuration (URL, signing secret, secret ID, retry policy) are provided by Frayme — these will move to self-service in the portal.