The Bank API

Invite applicants from your own systems, follow every review as it moves, read your whole book, and put the review inside your onboarding flow with one script tag.

Set it up with your coding agent

Paste one prompt into Claude Code, Codex, Cursor, Copilot or any other coding agent, from the root of your onboarding app. It adds the widget, the server-side token call and a verified webhook, and tests them in sandbox.

Read the prompt
Integrate the Apparently apply widget into this codebase's merchant onboarding flow.

Apparently reviews a gaming merchant's legal opinion for us (the bank or payment processor). The widget is a white-labelled iframe that shows our brand with a small "Powered by Apparently" footer. In it the merchant confirms their email with a one-time code, uploads the opinion, consents to share the result with us, and submits. We get the result in our Apparently Portfolio, and by signed webhook.

Read the codebase first. Find the onboarding step where a merchant should submit its legal opinion, the backend framework, and how secrets and env vars are configured. Follow the project's existing conventions. Do not add dependencies unless there is no reasonable alternative.

ENV VARS (server-side only; never ship them to a browser)
  APPARENTLY_API_KEY          ak_test_… while testing, ak_live_… in production (Portfolio → Settings → Developers)
  APPARENTLY_WEBHOOK_SECRET  whsec_… (shown once, when the webhook endpoint is registered)
  APPARENTLY_API_BASE         https://apparently.cc/api/v1/bank
Add them to the project's env example or config with placeholder values. Never commit real values.

ENDPOINTS (all under https://apparently.cc/api/v1/bank, with the header "Authorization: Bearer $APPARENTLY_API_KEY")
  POST /invites  (Idempotency-Key header required)      body { "email", "name", "external_ref" }  → data.id is the invitation id
  GET /invites/{id}
  POST /invites/{id}/apply-token  body { "return_url" }  → data { token: "bws_…", frame_url, hosted_url, expires_at }
  GET /applicants/{id}
  POST /webhooks  (Idempotency-Key header required)  body { "url", "events"? }  → the whsec_ secret, shown once
  POST /webhooks/{id}/test
  GET /events      the log webhooks are delivered from, to catch up on a missed delivery
  POST /sandbox/applicants/{id}/advance  sandbox keys only
Full reference: https://apparently.cc/banks/developers  ·  OpenAPI 3.1: https://apparently.cc/api/v1/bank/openapi.json

STEPS
1. Backend: when a merchant reaches the legal-opinion step, create the invitation once per merchant (reuse our own merchant id as external_ref, with a fresh Idempotency-Key per attempt that is stored and reused on retry), and store the returned invitation id on the merchant record.
2. Backend: add an authenticated endpoint on OUR server that calls POST /invites/{id}/apply-token for the current merchant and returns { token, frame_url } to our page. The API key stays on the server. An apply token lives 60 minutes; mint a fresh one per page load.
3. Frontend: on the onboarding page, load the script and mount the widget:
     <div id="apparently-apply"></div>
     <script src="https://apparently.cc/embed/apparently-bank.js"></script>
     const widget = ApparentlyBank.mount('#apparently-apply', {
       mode: 'apply', token, frameUrl,            // both from step 2
       returnUrl: '<the page after this step>',   // https; must be on a proved origin
       onEvent: (type, detail) => {},             // started, consented, document_uploaded, submitted, completed
       onSubmitted: () => {},                     // move the merchant to the next step
       onExpired: async () => widget.setToken(await fetchFreshApplyToken()),
     })
   If our onboarding cannot host an iframe, redirect the merchant to hosted_url instead.
4. Webhook: add a POST route (e.g. /webhooks/apparently) that reads the RAW body. Verify the "Apparently-Signature: t=<unix seconds>,v1=<hex>" header: v1 must equal HMAC-SHA256 of t + "." + raw body keyed with APPARENTLY_WEBHOOK_SECRET, compared in constant time, with t within 300 seconds of now. Answer 2xx fast, and deduplicate on the Apparently-Event-Id header. Record at least review.approved, review.conditions_set and review.denied on the merchant record. Events: invite.accepted, applicant.linked, review.started, review.findings_ready, review.revision_requested, review.conditions_set, review.approved, review.denied, monitoring.breach, document.requested, document.received.
5. Tests: unit-test the signature check (a valid signature, a tampered body, a stale timestamp) and the apply-token endpoint (it never returns the API key).

HUMAN STEPS (tell me which of these you could not do yourself)
- In Portfolio → Settings → Developers: mint a sandbox key, and list and prove the origin the onboarding page is served from (http://localhost is accepted for development). The widget can only be framed on a proved origin.
- In Dashboard → White label: set our name, logo, colour and support email.
- Register the webhook with POST /webhooks. It needs a public https URL, so use a tunnel in development, or poll GET /events.

TEST MODE
Use the ak_test_ key. Sandbox invitations are fixture applicants: nothing is emailed, nothing is charged, and the one-time code is always 000000. The outcome follows the invited email, like a test card: "+deny" is denied, "+conditions" is approved on conditions, anything else is approved. Move one along with POST /sandbox/applicants/{id}/advance, body { "to": "approved" }. Each move delivers its webhook at once.

DEFINITION OF DONE
- With the sandbox key, a test merchant reaches the step, the widget renders in our brand with "Powered by Apparently", the code 000000 verifies, a PDF uploads, consent is recorded, and submit fires onSubmitted.
- POST /sandbox/applicants/{id}/advance to approved delivers review.approved to our webhook, the signature verifies, and the merchant record shows approved.
- The API key and webhook secret appear only in server code and env config, never in client bundles, logs or the repo.
- The new tests pass. Summarise what you changed, which env vars to set, and the human steps still open.

Quickstart

  1. In Portfolio → Settings → Developers, mint a sandbox key (ak_test_…). Sandbox keys only ever touch fixture applicants.
  2. Invite an applicant from your server. Idempotency-Key is required, so a retried request never invites twice.
  3. Advance the sandbox applicant and watch your webhook endpoint receive each event, signed.
  4. Mint a live key (ak_live_…) when you are ready. Nothing else changes.
curl https://apparently.cc/api/v1/bank/invites \
  -H "Authorization: Bearer $APPARENTLY_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "email": "founder+conditions@example.com", "name": "Lantern Row", "external_ref": "APP-1042" }'

# → 201 { "data": { "id": "…", "status": "invited", "lane": "invited", "invite_url": null, … } }

curl https://apparently.cc/api/v1/bank/applicants?external_ref=APP-1042 -H "Authorization: Bearer $APPARENTLY_KEY"

Authentication

Every request carries Authorization: Bearer ak_live_… (or ak_test_…). Keys belong to your organisation, are shown once and stored hashed; an owner or admin revokes one from the Developers page and it stops working at once. A key without a route's scope gets 403; a missing, revoked or unknown key gets 401. Each key may make 120 requests a minute and 20,000 a day.

ScopeLets a key
invites:writeInvite applicants and mint widget tokens for them.
reviews:readRead applicants, their reviews and the event log.
book:readRead the whole book: every applicant with its risk and status.
webhooks:manageRegister, list and remove webhook endpoints.

Endpoints

Base URL https://apparently.cc/api/v1/bank. The whole API as OpenAPI 3.1: openapi.json. Every success is { data, meta }; every refusal is { error: { type, code, message } }.

RouteScopeWhat it does
GET /meany keyThe organisation, mode and scopes of the calling key
POST /invitesinvites:writeInvite an applicant to a legal-opinion review · Idempotency-Key required
POST /invites/bulkinvites:writeInvite up to 200 applicants at once; each row reports its own outcome · Idempotency-Key required
GET /invites/{id}invites:writeOne invitation and where it stands
POST /invites/{id}/widget-tokeninvites:writeA short-lived token for the embeddable widget
GET /applicantsreviews:readEvery applicant: lane, status, SLA, checklist, current review
GET /applicants/{id}reviews:readOne applicant
GET /reviews/{id}reviews:readOne review: client-safe findings, revisions, decision, conditions, links
GET /bookbook:readThe whole book, with risk and status
GET /eventsreviews:readThe event log webhooks are delivered from, newest first
GET /standardsreviews:readYour RLO review standard: every activated version, newest first, and the Apparently baseline
GET /standards/activereviews:readThe version new reviews are examined under (your active one, or the Apparently baseline)
POST /webhookswebhooks:manageRegister a webhook endpoint (the signing secret is shown once) · Idempotency-Key required
GET /webhookswebhooks:manageWebhook endpoints
GET /webhooks/{id}webhooks:manageOne endpoint and its recent deliveries
DELETE /webhooks/{id}webhooks:manageStop delivering to an endpoint
POST /webhooks/{id}/testwebhooks:manageSend a signed webhook.test event now
POST /sandbox/applicants/{id}/advanceinvites:writeSandbox only: move a fixture applicant to its next stage
POST /sandbox/applicants/{id}/simulateinvites:writeSandbox only: raise a monitoring or document event
GET /widget/sessionwidget tokenWhat the widget shows (widget token, not an API key)
POST /invites/{id}/apply-tokeninvites:writeA 60-minute apply token for the white-labelled apply widget, and its hosted link
GET /apply/sessionapply tokenWhat the apply widget shows: partner branding, the masked applicant email, the steps done
POST /apply/codeapply tokenEmail a one-time code to the invited applicant
POST /apply/verifyapply tokenExchange the one-time code for an apply session
POST /apply/consentapply sessionRecord the applicant's consent to share the result with the partner
POST /apply/upload-urlapply sessionA signed upload for the legal opinion or a supporting document
POST /apply/uploadsapply sessionCheck an uploaded file (type, size, virus scan) and add it to the review
POST /apply/websiteapply sessionLink the applicant's website and its public documents
POST /apply/submitapply sessionSubmit for review (consent required first)
GET /openapi.jsonpublicThis API as an OpenAPI 3.1 document

A review is the client-safe projection: findings with their title, tier, state and source, counsel's notes to the applicant, the decision and any conditions. Internal notes between you and counsel, and a finding's detail, never leave the Portfolio.

Webhooks

Register an https endpoint with POST /webhooks (or on the Developers page). The signing secret (whsec_…) is shown once. A sandbox key's endpoints hear sandbox events only; a live key's, live events only.

EventWhen
invite.acceptedThe applicant accepted the invitation.
applicant.linkedThe applicant linked one required item (repository, database, website, a public document or the opinion).
review.startedA review revision started.
review.findings_readyThe analysis finished; the findings are ready to read.
review.revision_requestedCounsel asked the applicant for changes (a review decided before conditional approvals).
review.conditions_setCounsel approved on conditions the applicant must meet. Not an approval yet: the applicant stays in remediation, and review.approved follows once counsel confirms every condition.
review.approvedThe review was approved (a conditional approval, once counsel confirmed every condition). Monitoring starts here.
review.deniedThe review was denied.
monitoring.breachA monitored applicant's attestation went into breach.
document.requestedMore documents were requested from the applicant.
document.receivedThe applicant supplied requested documents.

Every delivery carries Apparently-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of t + "." + raw body with your secret. Refuse a timestamp more than 300 seconds from your clock (a captured request cannot be replayed later), compare in constant time, and deduplicate on Apparently-Event-Id: a delivery that got no 2xx within 10 seconds is retried after 1 min, 5 min, 30 min, 2 h, 6 h (8 h 36 min in all), then abandoned.

Every delivered event is also in the log at GET /events, so a missed delivery can be caught up from it. GET /events lists newest first. For the next page pass the last event's id as starting_after (meta.next_starting_after holds it) until meta.has_more is false. Do not page by created_at: an event's created_at is when the fact became true, which can be earlier than when it was recorded, so paging by it skips events.

Node

import crypto from 'node:crypto'

// rawBody: the request body exactly as received (a Buffer or string), before JSON.parse.
export function verifyApparently(rawBody, header, secret, toleranceSeconds = 300) {
  let t = null
  const sigs = []
  for (const kv of String(header || '').split(',')) {
    const [k, v] = kv.split('=')
    if (k === 't') t = Number(v)
    if (k === 'v1') sigs.push(v)
  }
  if (!t || !sigs.length) return false
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false // replay window
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  return sigs.some(s => s.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}

Python

import hmac, hashlib, time

def verify_apparently(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    t, sigs = None, []
    for kv in (header or "").split(","):
        k, _, v = kv.partition("=")
        if k == "t" and v.isdigit():
            t = int(v)
        elif k == "v1":
            sigs.append(v)
    if t is None or not sigs or abs(int(time.time()) - t) > tolerance:  # replay window
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in sigs)

The widget

Put the review inside your own onboarding flow. Your server mints a widget token for an invitation (it lives 15 minutes and opens one applicant's status, nothing else); your page mounts the widget with it. Your API key never reaches a browser. List the origins your page is served from on the Developers page: the widget can only be framed there.

<!-- your onboarding page -->
<div id="apparently-review"></div>
<script src="https://apparently.cc/embed/apparently-bank.js"></script>
<script>
  // From YOUR server: POST https://apparently.cc/api/v1/bank/invites/{id}/widget-token
  // → { token, frame_url, expires_at }. Never call it from the browser.
  const widget = ApparentlyBank.mount('#apparently-review', {
    token: TOKEN_FROM_YOUR_SERVER,
    frameUrl: FRAME_URL_FROM_YOUR_SERVER,
    onStatus: (status) => console.log('review status:', status),
    onExpired: async () => widget.setToken(await fetchFreshTokenFromYourServer()),
  })
</script>

Statuses sent to onStatus: invited, accepted, linking, submitted, in_review, action_needed, conditionally_approved, approved, denied, withdrawn. The widget wears your brand (see the apply mode below), with a small "Powered by Apparently" footer.

The apply widget

Put the whole upload-for-review step inside your merchant onboarding, white-labelled as your brand: your name, logo, primary colour and support email (Dashboard → White label), a small "Powered by Apparently" footer, and the statement that the review is performed independently. The applicant:

  1. confirms the email you invited with a 6-digit code we send to that address (no third-party cookies: the session lives in the frame's sessionStorage and travels as a header);
  2. uploads the legal opinion (PDF or .docx) and any supporting public documents, and gives the website; linking code and database in the Apparently app is shown as optional and is not required to submit;
  3. agrees to "Share the results of this review with {your name}", which is recorded before anything is submitted;
  4. submits. Every file is type-checked and virus-scanned before the review starts; a file the scanner has not yet checked is held, never skipped.

Your server mints an apply token for the invitation with POST /invites/{id}/apply-token (it lives 60 minutes and opens that one invitation). Mount with mode: 'apply'. A partner that cannot frame sends the applicant to hosted_url (in the invitation answer and the apply-token answer) instead; return_url, on one of your proved origins, is where the "Return" button goes.

<div id="apparently-apply"></div>
<script src="https://apparently.cc/embed/apparently-bank.js"></script>
<script>
  // From YOUR server: POST https://apparently.cc/api/v1/bank/invites/{id}/apply-token  { "return_url": "https://onboarding.you.example/next" }
  // → { token: "bws_…", frame_url, hosted_url, expires_at }. Never call it from the browser.
  const widget = ApparentlyBank.mount('#apparently-apply', {
    mode: 'apply',
    token: APPLY_TOKEN_FROM_YOUR_SERVER,
    frameUrl: FRAME_URL_FROM_YOUR_SERVER,
    returnUrl: 'https://onboarding.you.example/next',
    onEvent: (type, detail) => console.log(type),   // started, consented, document_uploaded, submitted, completed
    onSubmitted: () => showNextStep(),
    onExpired: async () => widget.setToken(await fetchFreshApplyTokenFromYourServer()),
  })
</script>

Events: started, consented, document_uploaded, submitted, completed, plus the status events above. The iframe's sandbox allows forms and, on a click only, navigating your page to returnUrl. With a sandbox key nothing is emailed and the code is always 000000.

Sandbox

With an ak_test_ key, invitations are fixture applicants: no email, no real review, no charge. The outcome follows the email you invite, like a test card: an address containing +deny is denied, +conditions is approved on conditions, anything else is approved. Move one along with POST /sandbox/applicants/{id}/advance (optionally { "to": "approved" }), and raise monitoring.breach, document.requested or document.received with POST /sandbox/applicants/{id}/simulate. Each move delivers its webhooks at once.