Embed the widget, authenticate a user, connect a company via OAuth, and commit KYB/KYC/transaction profile data to the Identity Provider using a company-issued access token.
Try the authentication flow with our test credentials
Restart the authentication flow from the beginning
Embed the widget in your application
<!-- Embed the widget -->
<iframe
src="https://arn-widget-production.up.railway.app"
title="ARN Authentication Widget"
width="100%"
height="600px"
frameborder="0"
sandbox="allow-scripts allow-same-origin allow-forms"
></iframe>
The widget handles authentication internally. Use postMessage to pass configuration and receive events.
// Host app → widget (iframe)
const widgetOrigin = new URL('https://arn-widget-production.up.railway.app').origin;
iframe.contentWindow.postMessage({
type: 'GRAILVAULT_CONFIG',
payload: {
apiBaseUrl: 'https://<identity-provider-host>',
accessToken: null, // widget can obtain via its login flow
oauth: {
clientId: '<CompanyA OAuth client_id (uid)>',
redirectUri: 'https://company-a.com/oauth/callback',
scope: 'kyc.write kyb.write transactions.write',
state: '<generated-by-company-backend>'
}
}
}, widgetOrigin);
Important: never put the OAuth client_secret in the browser. The company backend must generate/validate state.
// Widget → host app
window.addEventListener('message', (event) => {
const widgetOrigin = new URL('https://arn-widget-production.up.railway.app').origin;
if (event.origin !== widgetOrigin) return;
if (event.source !== iframe.contentWindow) return;
const { type, payload } = event.data || {};
if (type === 'AUTH_COMPLETE') console.log('User logged in / account selected', payload);
if (type === 'COMPANY_CONNECTED') console.log('Company OAuth access granted', payload);
if (type === 'STEP_CHANGE') console.log('Widget step changed', payload);
if (type === 'ERROR') console.error('Widget error', payload);
});
All events use the same envelope shape: { type, payload }. Payload examples below match what the widget emits today.
// AUTH_COMPLETE
// - Emitted after login/registration verification:
{
"type": "AUTH_COMPLETE",
"payload": { "accessToken": "<JWT>", "authenticated": true }
}
// - Emitted after the user selects an account (demo compatibility):
{
"type": "AUTH_COMPLETE",
"payload": {
"account": "<MASKED_ACCOUNT_NUMBER>",
"routing": "<MASKED_ROUTING_NUMBER>",
"accountLast4": "1234",
"routingLast4": "5678"
}
}
// COMPANY_CONNECTED (OAuth consent confirmed)
{
"type": "COMPANY_CONNECTED",
"payload": { "connected": true }
}
// STEP_CHANGE
{
"type": "STEP_CHANGE",
"payload": { "step": "email-entry" }
}
// ERROR
{
"type": "ERROR",
"payload": { "message": "Human readable error message" }
}
import { ARNWidget } from '@grailpay/arn-widget';
function App() {
const handleAuthComplete = (accountData) => {
console.log('Authentication complete:', accountData);
};
return (
<ARNWidget
apiUrl="https://identity-provider.railway.app"
onAuthComplete={handleAuthComplete}
/>
);
}
NPM package coming soon. For now, use iframe embedding.
In the Identity Provider admin UI, create a Company (e.g. “Company A”), then create an OAuth Application under that company to obtain client_id and client_secret.
// Company backend receives redirect with ?code=...&state=...
// Validate `state`, then exchange code for tokens:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<AUTH_CODE>
&redirect_uri=https%3A%2F%2Fcompany-a.com%2Foauth%2Fcallback
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>
Store the returned access_token (and refresh_token) server-side, associated to the user in Company A’s system.
Send the CommitUserProfileRequest payload to the Identity Provider using the user-scoped OAuth access token.
POST /api/v1/third-party/data
Authorization: Bearer <OAUTH_ACCESS_TOKEN>
Content-Type: application/json
{
"accountId": "company-a-user-123",
"idempotencyKey": "optional-key",
"source": {
"system": "company_portal",
"provider": "company_a",
"eventId": "evt_123",
"capturedAt": "2026-01-02T12:00:00Z"
},
"profile": {
"kyc": { "name": "Steve" },
"transactions": [
{
"transactionDate": "2026-01-01",
"amount": { "value": 10.25, "currency": "USD" },
"direction": "debit"
}
]
}
}
Scopes are enforced by what’s present in profile (top-level keys):
// profile key → required OAuth scope
kyc → kyc.write
kyb → kyb.write
transactions → transactions.write
financialInstitutions → transactions.write
Canonical JSON Schema:
identity-provider/schemas/commit-user-profile-request.schema.json
(and referenced in identity-provider/API.md).
Note: the server currently enforces only accountId, non-empty profile, and OAuth scopes based on profile top-level keys.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.grailpay.com/auth/commit-user-profile-request.schema.json",
"title": "CommitUserProfileRequest",
"type": "object",
"additionalProperties": false,
"required": ["accountId", "profile"],
"properties": {
"accountId": {
"type": "string",
"minLength": 1,
"description": "User account identifier to attach the data to."
},
"idempotencyKey": {
"type": "string",
"minLength": 1,
"description": "Client-provided key to make this commit idempotent."
},
"source": {
"type": "object",
"additionalProperties": false,
"properties": {
"system": { "type": "string" },
"provider": { "type": "string" },
"eventId": { "type": "string" },
"capturedAt": { "type": "string", "format": "date-time" }
}
},
"profile": {
"type": "object",
"additionalProperties": false,
"properties": {
"financialInstitutions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"routingNumber": { "type": "string" },
"accountNumber": { "type": "string" },
"accountType": { "type": "string", "enum": ["checking", "savings", "other"] }
}
}
},
"transactions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["transactionDate", "amount", "direction"],
"properties": {
"transactionId": { "type": "string" },
"transactionDate": { "type": "string", "format": "date" },
"amount": {
"type": "object",
"additionalProperties": false,
"required": ["value", "currency"],
"properties": {
"value": { "type": "number" },
"currency": { "type": "string", "minLength": 3, "maxLength": 3 }
}
},
"direction": { "type": "string", "enum": ["credit", "debit"] },
"counterparty": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"account": { "type": "string" },
"routing": { "type": "string" }
}
}
}
}
},
"kyb": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"ein": { "type": "string" },
"physicalAddress": { "$ref": "#/$defs/addressWithSignals" },
"phone": { "$ref": "#/$defs/phoneWithSignals" },
"email": { "$ref": "#/$defs/emailWithSignals" },
"website": {
"type": "object",
"additionalProperties": false,
"properties": {
"url": { "type": "string" },
"status": { "type": "string", "enum": ["active", "inactive", "unknown"] },
"domainAgeDays": { "type": "integer", "minimum": 0 },
"associatedSocialMedia": {
"type": "array",
"items": { "type": "string" }
},
"associatedPhysicalAddresses": {
"type": "array",
"items": { "$ref": "#/$defs/address" }
}
}
}
}
},
"kyc": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"ssn": { "type": "string", "description": "Store/tokenize per policy; may be last4 only." },
"physicalAddress": { "$ref": "#/$defs/address" },
"phone": { "$ref": "#/$defs/phone" },
"email": { "$ref": "#/$defs/email" }
}
},
"location": {
"type": "object",
"additionalProperties": false,
"properties": {
"ipAddress": { "type": "string" },
"ipGeography": {
"type": "object",
"additionalProperties": false,
"properties": {
"country": { "type": "string" },
"region": { "type": "string" },
"city": { "type": "string" },
"postalCode": { "type": "string" },
"latitude": { "type": "number" },
"longitude": { "type": "number" }
}
},
"deviceId": { "type": "string" },
"ipDistanceToPhysicalAddressKm": { "type": "number", "minimum": 0 }
}
},
"other": {
"type": "object",
"description": "Optional extension bucket for future profile signals.",
"additionalProperties": true
}
}
}
},
"$defs": {
"address": {
"type": "object",
"additionalProperties": false,
"properties": {
"line1": { "type": "string" },
"line2": { "type": "string" },
"city": { "type": "string" },
"region": { "type": "string" },
"postalCode": { "type": "string" },
"country": { "type": "string" }
}
},
"addressWithSignals": {
"allOf": [
{ "$ref": "#/$defs/address" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"addressType": { "type": "string", "enum": ["residential", "business", "po_box", "unknown"] },
"addressToNameMatch": { "type": "boolean" },
"addressValidityLevel": { "type": "string", "enum": ["high", "medium", "low", "unknown"] }
}
}
]
},
"phone": {
"type": "object",
"additionalProperties": false,
"properties": {
"e164": { "type": "string", "description": "E.164 formatted phone number." }
}
},
"phoneWithSignals": {
"allOf": [
{ "$ref": "#/$defs/phone" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"lineType": { "type": "string", "enum": ["mobile", "landline", "voip", "unknown"] },
"carrier": { "type": "string" },
"firstSeenAt": { "type": "string", "format": "date-time" },
"lastSeenAt": { "type": "string", "format": "date-time" },
"matches": {
"type": "object",
"additionalProperties": false,
"properties": {
"phoneToNameMatch": { "type": "boolean" },
"phoneToEmailMatch": { "type": "boolean" },
"phoneToAddressMatch": { "type": "boolean" }
}
}
}
}
]
},
"email": {
"type": "object",
"additionalProperties": false,
"properties": {
"address": { "type": "string" }
}
},
"emailWithSignals": {
"allOf": [
{ "$ref": "#/$defs/email" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"emailValid": { "type": "boolean" },
"firstSeenAt": { "type": "string", "format": "date-time" },
"lastSeenAt": { "type": "string", "format": "date-time" },
"emailDomain": { "type": "string" },
"emailToNameMatch": { "type": "boolean" },
"emailDisposable": { "type": "boolean" },
"emailRiskScore": { "type": "number", "minimum": 0, "maximum": 1 }
}
}
]
}
}
}
API docs live in the repo at identity-provider/API.md (paths above are the canonical integration surface).