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
- In Portfolio → Settings → Developers, mint a sandbox key (
ak_test_…). Sandbox keys only ever touch fixture applicants. - Invite an applicant from your server.
Idempotency-Key is required, so a retried request never invites twice. - Advance the sandbox applicant and watch your webhook endpoint receive each event, signed.
- 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.
| Scope | Lets a key |
|---|
invites:write | Invite applicants and mint widget tokens for them. |
reviews:read | Read applicants, their reviews and the event log. |
book:read | Read the whole book: every applicant with its risk and status. |
webhooks:manage | Register, 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 } }.
| Route | Scope | What it does |
|---|
GET /me | any key | The organisation, mode and scopes of the calling key |
POST /invites | invites:write | Invite an applicant to a legal-opinion review · Idempotency-Key required |
POST /invites/bulk | invites:write | Invite up to 200 applicants at once; each row reports its own outcome · Idempotency-Key required |
GET /invites/{id} | invites:write | One invitation and where it stands |
POST /invites/{id}/widget-token | invites:write | A short-lived token for the embeddable widget |
GET /applicants | reviews:read | Every applicant: lane, status, SLA, checklist, current review |
GET /applicants/{id} | reviews:read | One applicant |
GET /reviews/{id} | reviews:read | One review: client-safe findings, revisions, decision, conditions, links |
GET /book | book:read | The whole book, with risk and status |
GET /events | reviews:read | The event log webhooks are delivered from, newest first |
GET /standards | reviews:read | Your RLO review standard: every activated version, newest first, and the Apparently baseline |
GET /standards/active | reviews:read | The version new reviews are examined under (your active one, or the Apparently baseline) |
POST /webhooks | webhooks:manage | Register a webhook endpoint (the signing secret is shown once) · Idempotency-Key required |
GET /webhooks | webhooks:manage | Webhook endpoints |
GET /webhooks/{id} | webhooks:manage | One endpoint and its recent deliveries |
DELETE /webhooks/{id} | webhooks:manage | Stop delivering to an endpoint |
POST /webhooks/{id}/test | webhooks:manage | Send a signed webhook.test event now |
POST /sandbox/applicants/{id}/advance | invites:write | Sandbox only: move a fixture applicant to its next stage |
POST /sandbox/applicants/{id}/simulate | invites:write | Sandbox only: raise a monitoring or document event |
GET /widget/session | widget token | What the widget shows (widget token, not an API key) |
POST /invites/{id}/apply-token | invites:write | A 60-minute apply token for the white-labelled apply widget, and its hosted link |
GET /apply/session | apply token | What the apply widget shows: partner branding, the masked applicant email, the steps done |
POST /apply/code | apply token | Email a one-time code to the invited applicant |
POST /apply/verify | apply token | Exchange the one-time code for an apply session |
POST /apply/consent | apply session | Record the applicant's consent to share the result with the partner |
POST /apply/upload-url | apply session | A signed upload for the legal opinion or a supporting document |
POST /apply/uploads | apply session | Check an uploaded file (type, size, virus scan) and add it to the review |
POST /apply/website | apply session | Link the applicant's website and its public documents |
POST /apply/submit | apply session | Submit for review (consent required first) |
GET /openapi.json | public | This 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.
| Event | When |
|---|
invite.accepted | The applicant accepted the invitation. |
applicant.linked | The applicant linked one required item (repository, database, website, a public document or the opinion). |
review.started | A review revision started. |
review.findings_ready | The analysis finished; the findings are ready to read. |
review.revision_requested | Counsel asked the applicant for changes (a review decided before conditional approvals). |
review.conditions_set | Counsel 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.approved | The review was approved (a conditional approval, once counsel confirmed every condition). Monitoring starts here. |
review.denied | The review was denied. |
monitoring.breach | A monitored applicant's attestation went into breach. |
document.requested | More documents were requested from the applicant. |
document.received | The 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 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:
- 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);
- 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;
- agrees to "Share the results of this review with {your name}", which is recorded before anything is submitted;
- 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.