On this page
DAF Connect Developer Guide
Everything you need to embed nonprofit onboarding into your site — from a one-line iframe install to deep integration with the postMessage API, custom fields, verification pipelines, and webhook callbacks.
Introduction
DAF Connect is a hosted, embeddable onboarding and verification platform for nonprofit-to-enterprise relationships. It handles intake, identity verification, document collection, fraud screening, and reviewer workflows so your team can focus on grantmaking decisions instead of paperwork.
You integrate by dropping a single iframe onto your site. Nonprofits complete an 11-step intake (step 1 collects the legal name, EIN, mailing address, and applicant contact; step 2 verifies the organization); your reviewers receive structured submissions enriched with IRS, OFAC, Stripe, and AI verification data. Onboarding progress — load, step changes, resize, errors and submission — is pushed to your page via the postMessage API so you can react in real time; reviewer decisions after submission (in review, approved, rejected, more info requested, published, withdrawn) reach your backend as server webhooks.
How it works
- Configure your enterprise in the DAF Connect dashboard — branding, required fields, custom questions, allowed domains.
- Embed the iframe on your site using your public key.
- Nonprofits complete onboarding — company details and verification, bank account, signer identity, mission, leadership, a security check, financials, your custom questions, documents, then review and submit.
- Verification pipeline runs automatically — IRS, OFAC, exclusions, web research, document scans, AI review, trust score. First results land in seconds.
- Your reviewers approve, reject, or request more info from the reviewer dashboard.
- Email notifications fire to the applicant — a receipt when they submit, then on reviewer decisions (approve / reject / more info / published).
- Your site receives postMessage callbacks at every step to update UI in real time.
Quickstart
Drop this snippet on the page where you want nonprofits to onboard. The iframe handles every step — Stripe verifications, document uploads, encryption. You just listen for events.
<!-- 1. Container -->
<div id="dafconnect"></div>
<!-- 2. Embed script -->
<script>
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY'; // From dashboard → Embed
const iframe = document.createElement('iframe');
iframe.src = 'https://daf-connect.com/embed/' + PUBLIC_KEY;
iframe.style.cssText = 'width:100%;height:800px;border:none;border-radius:12px;';
iframe.allow = 'camera; microphone; clipboard-write';
document.getElementById('dafconnect').appendChild(iframe);
// 3. Listen for events
window.addEventListener('message', (e) => {
if (!e.data?.type?.startsWith('dafconnect:')) return;
console.log('[DAF Connect]', e.data.type, e.data);
if (e.data.type === 'dafconnect:resized') {
iframe.style.height = (e.data.height + 20) + 'px';
}
if (e.data.type === 'dafconnect:complete') {
// Onboarding finished — refresh your UI
window.location.reload();
}
});
</script>Requirements
| Requirement | Why |
|---|---|
| HTTPS in production | Required for camera/microphone access (Stripe Identity) |
| Modern browser (ES6+) | Iframe uses postMessage, ResizeObserver, async/await |
| Public key from dashboard | Identifies your enterprise — found at /app/embed |
| Domain on allowlist | Security — only listed parent domains can embed the iframe |
| camera/microphone allow attribute | Required for Stripe Identity ID verification |
| Backend not required | All verification & state happens server-side at DAF Connect, on Base44 — the platform this application runs on |
Iframe embed
The iframe is loaded from /embed/:public_key:
<iframe
src="https://daf-connect.com/embed/pk_abc123xyz"
style="width:100%; height:800px; border:none; border-radius:12px;"
allow="camera; microphone; clipboard-write"
title="Nonprofit onboarding"
></iframe>Iframe attributes
| Attribute | Recommended value | Notes |
|---|---|---|
src | /embed/{your_public_key} | Required |
allow | camera; microphone; clipboard-write | Required for Stripe Identity |
title | Nonprofit onboarding | For accessibility |
loading | lazy | Optional — defer until in viewport |
referrerpolicy | no-referrer-when-downgrade | Used by domain allowlist |
Hosted onboarding page
If you don't want to embed an iframe, link directly to the hosted page at /onboard/:public_key. Useful for emails, QR codes, or shorter integrations.
<a href="https://daf-connect.com/onboard/pk_abc123xyz" target="_blank">
Start nonprofit application →
</a>/embed/ it emits no dafconnect:error, and it never emits dafconnect:resized either. Frame /embed/ when your page needs those events.Domain allowlist
For security, the iframe refuses to render if embedded on a domain that isn't on your enterprise's allowlist. Configure your allowed domains at /app/embed in the dashboard.
ancestorOrigins), so don't set referrerpolicy="no-referrer" on the iframe or the host page. Both cases emit dafconnect:error with code unauthorized_origin.{
"allowed_domains": [
"yoursite.com",
"staging.yoursite.com",
"localhost"
]
}yoursite.com also matches www.yoursite.com and app.yoursite.com. Don't include the protocol or trailing slash.allowed_domains list does not mean "allow everywhere" — it authorizes no site, so the embed renders nowhere until you add a domain. And when a list is configured but the parent's origin cannot be determined (no referrer and no ancestorOrigins), the embed is refused rather than allowed, so a stripped referrer is not a way around the list. Framing from DAF Connect's own origin (the Embed Manager preview) is exempt.When a request comes from an unauthorized origin, the iframe shows an "Embed not authorized" screen and emits dafconnect:error with code unauthorized_origin.
Responsive height
When embedded, the iframe broadcasts its content height whenever it changes (via ResizeObserver). Resize your iframe to match to avoid double scrollbars. The simplest way is the hosted helper the dashboard's copy-paste snippet already includes — drop it anywhere on the page:
<script src="https://daf-connect.com/embed-resizer.js" async></script>It finds your DAF Connect iframe (the first one whose src contains /embed/ or /onboard/, even one inserted later inside a modal), listens for the resize event, and sets the iframe height to match — accepting only messages that come from that iframe's own window and origin. Give the script a data-key="YOUR_PUBLIC_KEY" (and optionally data-container="#dafconnect") and it will create the iframe for you. Only the /embed/ route emits resize events — a framed hosted page does not. To handle it yourself:
window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:resized') {
const iframe = document.querySelector('#dafconnect iframe');
iframe.style.height = (e.data.height + 20) + 'px';
}
});Modal pattern
Common pattern — open the iframe inside a modal triggered by a button:
<button id="open-onboarding">Apply to our giving program</button>
<div id="dafc-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6);
z-index:9999; align-items:center; justify-content:center;">
<div style="background:white; width:90%; max-width:700px; height:85vh; border-radius:16px;
overflow:hidden; position:relative;">
<button id="dafc-close" style="position:absolute; top:12px; right:12px; z-index:1;
background:rgba(0,0,0,0.08); border:none; width:32px; height:32px; border-radius:50%;
cursor:pointer;">✕</button>
<iframe id="dafc-iframe" style="width:100%; height:100%; border:none;"
allow="camera; microphone"></iframe>
</div>
</div>
<script>
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY'; // From dashboard → Embed
const modal = document.getElementById('dafc-modal');
const iframe = document.getElementById('dafc-iframe');
document.getElementById('open-onboarding').onclick = () => {
iframe.src = 'https://daf-connect.com/embed/' + PUBLIC_KEY;
modal.style.display = 'flex';
};
document.getElementById('dafc-close').onclick = () => {
modal.style.display = 'none';
iframe.src = '';
};
window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:complete') {
modal.style.display = 'none';
iframe.src = '';
alert('Application submitted! Reference: ' + e.data.submissionRef);
}
});
</script>Events
All communication between the iframe and your parent page uses the Window.postMessage API.
| Event | Direction | Fired when |
|---|---|---|
| dafconnect:loaded | out | Iframe finishes initial load |
| dafconnect:stepChanged | out | User navigates to a new step (1–11) |
| dafconnect:resized | out | Iframe content height changes |
| dafconnect:submitted | out | Submission persisted (fires first) |
| dafconnect:complete | out | Thank-you screen renders — the one to listen for |
| dafconnect:toast | out | Non-critical notification |
| dafconnect:error | out | Critical error blocking continuation |
| dafconnect:prefill | in | You send → iframe pre-fills fields |
All messages include a type field starting with dafconnect:. In production, always verify event.origin before trusting message contents.
dafconnect:loaded
Fired once when the iframe successfully loads and is ready for user input. Use this to hide loading spinners.
{
"type": "dafconnect:loaded",
"enterpriseSlug": "your-org-slug"
}dafconnect:stepChanged
Fired every time the user navigates between the 11 onboarding steps. Useful for analytics funnels and external progress bars.
{
"type": "dafconnect:stepChanged",
"step": 5, // current step (1-11)
"totalSteps": 11
}Step map: 1=Organization (legal name, EIN, address, contact) · 2=Company verification · 3=Bank account · 4=Signer identity · 5=Mission & programs · 6=Leadership contacts · 7=Financials · 8=Additional questions · 9=Documents · 10=Review · 11=Submit
dafconnect:resized
Fired whenever the iframe's content height changes. Resize your iframe to prevent inner scrollbars.
{
"type": "dafconnect:resized",
"height": 1280 // pixels
}dafconnect:resized (past tense), not resize.dafconnect:submitted
Fired once the submit request returns and the submission is persisted on the server — not on the click itself. If the request fails you receive dafconnect:error instead. Verification runs asynchronously after this event — so the submission exists in your reviewer queue but verification may still be running.
{
"type": "dafconnect:submitted",
"submissionRef": "sub_abc123xyz789"
}submissionRef for later cross-reference with your reviewer dashboard.dafconnect:complete
The most important event to listen for. Fired the moment the submission is fully persisted and the thank-you screen renders. This is your cue to redirect, close a modal, refresh a list, or whatever your post-onboarding flow needs.
Order of events: the iframe emits dafconnect:submitted first, then dafconnect:complete immediately after, then dafconnect:toast. Both submitted and complete carry the same submissionRef — listen for whichever fits your mental model. We recommend complete.
{
"type": "dafconnect:complete",
"submissionRef": "sub_abc123xyz789"
}How it works internally
The iframe uses a tiny postMsg() helper to broadcast the event to its parent window the instant the thank-you screen renders. This is the same mechanism powering every otherdafconnect:* event:
// Inside the OnboardingFlow component when submission succeeds:
postMsg('dafconnect:complete', { submissionRef: submission.id });
// The helper itself:
function postMsg(type, payload = {}) {
if (window.parent !== window) {
window.parent.postMessage({ type, ...payload }, '*');
}
}Listening on your parent page
Drop this on the page that hosts the iframe:
window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:complete') {
console.log('Onboarding complete:', e.data.submissionRef);
// Redirect, close modal, refresh list, etc.
}
});Common patterns
Pick the pattern that matches your integration:
window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:complete') {
// Pass the submission ref so your thank-you page can show it
window.location.href = '/applied?ref=' + e.data.submissionRef;
}
});window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:complete') {
document.getElementById('dafc-modal').style.display = 'none';
document.getElementById('dafc-iframe').src = ''; // unmount iframe
showToast('Application submitted! Reference: ' + e.data.submissionRef);
}
});window.addEventListener('message', async (e) => {
if (e.data?.type === 'dafconnect:complete') {
// Re-fetch your "Applications" list from your own backend
await refetchApplications();
// Optionally store the ref for cross-referencing later
localStorage.setItem('last_dafc_ref', e.data.submissionRef);
}
});window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:complete') {
analytics.track('Nonprofit Application Completed', {
submission_ref: e.data.submissionRef,
enterprise: 'your-org-slug',
source: 'embedded_iframe'
});
}
});function ApplyPage() {
const [submissionRef, setSubmissionRef] = useState(null);
useEffect(() => {
const handler = (e) => {
if (e.data?.type === 'dafconnect:complete') {
setSubmissionRef(e.data.submissionRef);
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, []);
if (submissionRef) {
return <div>Thanks! Reference: {submissionRef}</div>;
}
// Your own iframe wrapper component
return <YourDafConnectIframe />;
}postMessage events can come from any iframe on your page. Always check e.origin before trusting the payload:window.addEventListener('message', (e) => {
// Reject messages from any other origin
if (e.origin !== 'https://daf-connect.com') return;
if (e.data?.type !== 'dafconnect:complete') return;
// Now safe to trust
handleCompletion(e.data.submissionRef);
});What to do with the submissionRef
- Store it in your own database to cross-reference applications
- Show it on your confirmation screen so applicants can quote it in support tickets
- Use it as a query parameter on your redirect URL
- Look it up later in the reviewer dashboard at
/app/submissions/:ref
complete — it's the safer signal.dafconnect:toast
Non-critical UX notifications fired after submission. You can ignore these or surface them in your own toast system.
{
"type": "dafconnect:toast",
"message": "✓ Application submitted successfully!",
"toastType": "success"
}dafconnect:error
Critical errors that prevent the user from continuing. Surface these in your own UI or alerting.
{
"type": "dafconnect:error",
"code": "unauthorized_origin" | "submit_failed" | "verification_failed",
"message": "Human-readable error description"
}Error codes
| Code | Meaning | Action |
|---|---|---|
| unauthorized_origin | Framing not allowed. Fires when the enterprise has no allowed domains configured yet (the allowlist fails closed), when the parent origin cannot be determined (no ancestorOrigins and the referrer was stripped), or when the parent host is neither on the allowlist nor a subdomain of an entry. message says which. | Add the embedding domain under Embed Manager → Allowed domains, and do not strip the referrer on the parent page |
| submit_failed | Submission API call failed | Check console, retry |
| verification_failed | The server-side security check (step 7) refused the applicant; the flow is locked | Not retryable by the applicant — they should contact the enterprise |
public_key at /app/embed.dafconnect:prefill (inbound)
You send this to the iframe to pre-populate fields. Useful when the applicant is already logged into your platform and you know their org details. Note that step 1 (Organization) reads its mailing address, email, phone, and website from the applicant_* keys below — the plain contact_*/address_* keys instead prefill the authorized-signer contact used later in company verification and identity. Send both sets to fill every field.
const iframe = document.querySelector('#dafconnect iframe');
iframe.contentWindow.postMessage({
type: 'dafconnect:prefill',
data: {
legal_name: 'Acme Nonprofit Inc',
dba: 'Acme',
website: 'https://acme.org',
// Step 1 (Organization) — mailing address & primary contact
applicant_address: '123 Main St, Austin, TX 78701',
applicant_email: 'jane@acme.org',
applicant_phone: '555-0100',
applicant_website: 'https://acme.org',
// Authorized signer / company-verification contact (steps 2 & 4)
contact_name: 'Jane Smith',
contact_email: 'jane@acme.org',
contact_phone: '555-0100',
mission_statement: 'We provide...',
mission_area: 'Housing',
ntee_code: 'B20',
founded_year: 2015,
address_street: '123 Main St',
address_city: 'Austin',
address_state: 'TX',
address_zip: '78701'
}
}, '*');Only these keys are accepted: legal_name, dba, website, applicant_address, applicant_email, applicant_phone, applicant_website, contact_name, contact_email, contact_phone, mission_statement, mission_area, ntee_code, founded_year, address_street, address_city, address_state and address_zip. Anything else — EIN, bank details, signer or identity fields, verification flags, leadership contacts, financials — is silently ignored, with no error event. The message must come from the iframe's direct parent window.
Enterprise settings
All onboarding behaviour is controlled by your Enterprise record, configurable at /app/branding, /app/embed, and /app/settings.
| Field | Type | Description |
|---|---|---|
| name | string | Display name shown in iframe header & emails |
| slug | string | URL-safe identifier |
| logo_url | string | Logo shown in iframe header (200×50 recommended) |
| primary_color | string | Progress rail + header brand square (when no logo) in the wizard; 4px top bar on verification-link pages. Hex, e.g. #006044 |
| accent_color | string | Continue/Submit CTA, verification "Start" buttons and success-screen icon. Hex, e.g. #006990 |
| intro_copy | string | Welcome text on step 1 |
| success_message | string | Thank-you screen message |
| allowed_domains | string[] | Domain allowlist for iframe embedding |
| custom_fields | array | Custom questions added to step 9 (Additional questions) |
| features | object | Feature toggles — verification (stripe_verifications_enabled, giact_verifications_enabled, ai_verification_enabled, auto_verify_enabled), review (auto_approve_enabled, auto_approve_threshold, kanban_board_enabled), network and security (share_graph_names, dark_web_monitoring_enabled, mfa_policy). See Feature flags. |
| feature_flags | object | White-label toggle (hide_daf_branding) and the data-retention override (retention_policy) |
Branding (logo & colors)
{
"logo_url": "https://yourcdn.com/logo.svg",
"primary_color": "#006044",
"accent_color": "#006990"
}primary_color sets the top accent border and titles.accent_color sets all CTA buttons and progress indicators.
Required fields
The wizard always enforces: on step 1 legal_name, a 9-digit ein, applicant_address, applicant_email and applicant_phone; on step 6 two to five leadership contacts (each with name, email, phone and address) with exactly one primary contact who is identity-verified, or who has a government ID uploaded on the manual and GIACT rails; and the verification steps 2 to 4 for the configured rail. Mission (step 5) and Financials (step 8) are optional for applicants. Required documents (step 10) are waived once all three Stripe verifications have passed.
Custom questions
Add organization-specific intake questions on step 9 (Additional questions):
{
"custom_fields": [
{
"key": "service_area",
"type": "dropdown",
"label": "Primary service area",
"required": true,
"options": ["Education", "Healthcare", "Housing", "Environment", "Other"]
},
{
"key": "annual_beneficiaries",
"type": "text",
"label": "Approximate beneficiaries per year",
"placeholder": "e.g. 5,000",
"required": false
},
{
"key": "partnership_history",
"type": "textarea",
"label": "Tell us about your past corporate partnerships",
"placeholder": "1-2 paragraphs",
"required": false
},
{
"key": "previous_grant",
"type": "yes_no",
"label": "Have you received a grant from us before?",
"required": true
}
]
}Responses are saved on Submission.custom_field_values keyed by the field's key and displayed in the "Custom enterprise questions" section of the reviewer's Organization tab.
Supported field types
| Type | Renders as |
|---|---|
| text | Single-line input (default if type is omitted) |
| textarea | Multi-line text box (3 rows) |
| dropdown | Select dropdown — requires options array |
| yes_no | Two radio buttons (Yes / No) |
key, a label, and optionally required: true and placeholder.Success message & redirect
The thank-you screen shows your enterprise's success_message text. The iframe does not auto-redirect — handle redirects yourself by listening for the dafconnect:complete event on the parent page:
window.addEventListener('message', (e) => {
if (e.data?.type === 'dafconnect:complete') {
window.location.href = '/applied?ref=' + e.data.submissionRef;
}
});Feature flags
Enterprise-level feature toggles. stripe_verifications_enabled — along with auto-verification, hide-score, fraud-network name sharing, team MFA policy and data retention — can be changed by an owner or admin on /app/settings (the server rejects saves from other members). The rest, including ai_verification_enabled and hide_daf_branding, are platform-managed — contact your account manager to enable them:
{
// Verification toggles live on Enterprise.features
"features": {
"stripe_verifications_enabled": true, // Stripe Connect + Identity + Financial Connections
"ai_verification_enabled": true, // AI-powered risk recommendation
"giact_verifications_enabled": false, // Adds the four GIACT checks (opt-in)
"charityapi_verifications_enabled": true, // CharityAPI.org IRS registry corroboration (on unless false)
"dark_web_monitoring_enabled": true, // Klaw exposure scan of contacts (on unless false)
"auto_approve_enabled": false, // Auto-approve at or above auto_approve_threshold
"auto_approve_threshold": 80, // Trust score needed to auto-approve (default 80)
"verification_cache_days": 30, // Example: reuse a passed identity check for 30 days
"share_graph_names": true // Show your org names to a matched workspace (on unless false)
},
// White-label toggle lives on Enterprise.feature_flags
"feature_flags": {
"hide_daf_branding": false // Removes "Secured by DAF Connect" footer
}
}| Flag | Location | Effect |
|---|---|---|
| stripe_verifications_enabled | features | Step 2/3/4 use Stripe (real verification) instead of manual entry |
| ai_verification_enabled | features | Runs AI verification as a pipeline stage and adds an AI section to the Verification tab (Run / Re-run, an approve | review | reject recommendation with confidence, reasoning and flags), plus an AI Recommendation banner on Overview |
| giact_verifications_enabled | features | Adds the four GIACT checks (sanctions, business identity, email intelligence, bank verification) to the pipeline — off by default |
| charityapi_verifications_enabled | features | CharityAPI.org IRS registry corroboration — four route-to-review checks; the CharityAPI key is platform-wide, so this is on for every workspace unless set to false |
| dark_web_monitoring_enabled | features | Dark-web & surface-web exposure scan of contact addresses (Klaw) — on unless set to false |
| auto_approve_enabled | features | Automatically approve submissions whose trust score reaches auto_approve_threshold — off by default |
| auto_approve_threshold | features | Trust score a submission must reach to auto-approve (default 80) |
| verification_cache_days | features | Reuse a passed signer-identity verification for this many days instead of re-running (and re-charging) it; unset = reuse any prior pass |
| share_graph_names | features | When an entity-graph match links you to another workspace, show your organization names to it in the clear (on unless false) or anonymized |
| hide_daf_branding | feature_flags | Removes "Secured by DAF Connect" footer |
Flow overview (11 steps)
The applicant moves through these steps:
| # | Step | Collects | Required? |
|---|---|---|---|
| 1 | Organization | Legal name | Always |
| 2 | Company verify | EIN, address, contact (via Stripe Connect or manual) | Always |
| 3 | Bank account | Routing + account (via Stripe FC or manual) | Always |
| 4 | Signer identity | Government ID (via Stripe Identity or manual) | Always |
| 5 | Mission | Mission statement, NTEE code, founded year, website | Config-driven |
| 6 | Leadership contacts | 2–5 organization contacts (name, email, phone, address); exactly one primary whose identity is verified (Stripe rail) or whose government ID is uploaded (manual/GIACT rails) | Always |
| 7 | Financials | Revenue / expenses / total assets for the last 3 fiscal years, total liabilities (most recent year), mission area (NTEE) | Optional |
| 8 | Custom questions | Enterprise-specific questions | Per-field |
| 9 | Documents | IRS letter, Form 990, bylaws, etc. | Per-doc-type |
| 10 | Review | Read-only summary with edit links | Always |
| 11 | Submit | Final confirmation | Always |
Drafts auto-save to localStorage so users can close and resume later from the same browser. Sensitive values — the EIN, bank account and routing numbers, and the signer's ID file — are never written to the draft and must be re-entered on resume: a resumed draft is clamped back to step 1 until the EIN is re-entered, and on the manual and GIACT rails to step 3 or 4 until the bank details or signer ID are re-supplied. A human-verification challenge (Cloudflare Turnstile) is visible for the whole flow and its token is verified server-side at submit.
passed, and the backend re-checks this before opening any Stripe session. An inconclusive result holds the applicant on step 1 with a banner explaining what to fix; a failed_confirmed result auto-rejects the submission. Sandbox, demo and emulated enterprises pass this gate automatically.Stripe vs manual mode
Steps 2/3/4 (Company, Bank, Identity) operate in one of two modes depending on the stripe_verifications_enabled feature flag:
| Mode | Behaviour |
|---|---|
| Stripe ON | Live Stripe Connect, Financial Connections & Identity flows. Bank ownership is verified against legal name with a match score. Identity verifies government ID. |
| Stripe OFF | Manual entry — applicant types bank/EIN, uploads a government ID file. Reviewer verifies manually. |
Data encryption
Sensitive fields are encrypted with AES-256-GCM using a per-enterprise key before being stored. Only authorized reviewers from your enterprise can decrypt.
Encrypted fields
nonprofit_ein— EIN tax IDbanking_account_name— Account holder namebanking_routing— Routing numberbanking_account— Account numberbanking_bank_name— Bank namesigner_id_file_url— Signer ID document URLsigner_id_file_name— Signer ID filename
Document uploads
Step 9 collects these documents. The list of types is fixed, but which of them are required is per enterprise: the Embed Manager's "Required documents" toggles ( required_fields) override the defaults below for w9, form_990, audited_financials, insurance and board_list. 501c3_letter has no toggle and keeps its default.
| Doc type key | Label | Required | Max files | Formats |
|---|---|---|---|---|
| 501c3_letter | IRS 501(c)(3) Determination Letter | Yes* | 1 | PDF, JPG, PNG |
| w9 | IRS Form W-9 | Yes* | 1 | PDF only |
| form_990 | IRS Form 990 (most recent 3 fiscal years) | Yes* | 3 | PDF only |
| audited_financials | Audited Financial Statements | Optional | 3 | PDF only |
| insurance | Certificate of Insurance | Optional | 2 | PDF, JPG, PNG |
| board_list | Board Resolution Authorizing Banking | Optional | 1 | PDF only |
Max file size: 25 MB per file. Validation: Magic-byte MIME validation (file contents must actually match the extension), SHA-256 deduplication, filename sanitization.
org_verified), bank account (bank_verified), and signer identity (identity_verified) — the applicant's legitimacy is already established electronically, so no document is required to continue. The applicant can submit immediately, though uploading the 501(c)(3) letter and Form 990 still helps reviewers approve faster. In manual mode (Stripe off), the three documents marked above remain required.Virus scanning
Documents are uploaded to private storage during onboarding. Virus scanning via VirusTotal happens after submission in the reviewer panel — reviewers see scan status (Pending / Clean / Infected — or Error when the scanner could not be reached) on each document before opening it; the applicant's own upload list shows the same infected state as Quarantined. Infected files are flagged in the reviewer UI, and a document that has not been scanned is never shown as clean.
Pipeline overview
The instant the applicant clicks Submit, an asynchronous verification pipeline runs against the submission. First results reach the dashboard in seconds; the research-heavy checks (web research, AI verification) typically complete within a couple of minutes.
23 automated checks
| # | Check | Source | What it does |
|---|---|---|---|
| 1 | EIN format | Internal regex | Validates XX-XXXXXXX pattern |
| 2 | IRS 501(c)(3) | IRS BMF / ProPublica | Confirms active 501(c)(3) status |
| 3 | OFAC screening | US Treasury SDN list | Sanctions list match check (org + officers/board) |
| 4 | Exclusions screening | SAM.gov + HHS OIG LEIE | Federal debarment & healthcare exclusion match — always route-to-review, never auto-reject |
| 5 | Document validation | Internal MIME/size check | Pipeline-side recheck (≤25MB, allowed MIME types) |
| 6 | Virus scanning | VirusTotal | Every uploaded document; per-doc status in the reviewer panel |
| 7 | Form 990 financials | ProPublica | Pulls the filing history that feeds financial forensics |
| 8 | Web research | LLM + live web | Online presence strength and red flags |
| 9 | Applicant IP intelligence | Internal | VPN detection, geolocation vs stated address, IP changes mid-application |
| 10 | Drift detection | Internal | Detects changes since last submission |
| 11 | Entity-graph edges | Internal | Shared officers, addresses, and bank accounts across applicants (ring detection) |
| 12 | Org trust score | Internal model | 0–100 org trust score based on history & drift |
| 13 | AI verification (opt) | LLM | Approve/Review/Reject recommendation with reasoning |
| 14 | Stripe Tax ID (opt) | Stripe | Validates EIN matches IRS via Stripe |
| 15 | GIACT sanctions screen (opt) | GIACT gOFAC (LSEG Risk Intelligence) | Independent second sanctions/watchlist rail over org + officers + board |
| 16 | GIACT business identity (opt) | GIACT gIdentify Business | Business identity + live IRS TIN gateway match on the org |
| 17 | GIACT email intelligence (opt) | GIACT ESI | Email authenticity, age, and fraud-history signals on the applicant email |
| 18 | GIACT bank verification (opt) | GIACT gVerify / gAuthenticate / Open Banking | Account open/active status + ownership match — typed-in details verified post-submit, or connected live through GIACT’s hosted Open Banking widget |
| 19 | Submission trust score | Internal heuristic | 0–100 composite (higher = more trustworthy), saved to Submission.score |
| 20 | IRS Pub. 78 listing | CharityAPI.org (IRS Pub. 78) | Public-charity listing for the EIN — needs CharityAPI; route-to-review only, never moves the score |
| 21 | IRS registry match | CharityAPI.org or ProPublica (IRS BMF) | Applicant-entered name, address, in-care-of, NTEE, founded year and org type vs the IRS Business Master File record |
| 22 | IRS foundation type | CharityAPI.org or ProPublica (IRS BMF) | Foundation / deductibility / status codes → DAF-eligibility posture (private foundations and Type III non-FI supporting orgs route to review) |
| 23 | IRS registries compared | ProPublica + CharityAPI.org | "Data sources differ" when the two IRS mirrors disagree on a field; skipped when only one registry answers |
The GIACT checks (15–18) are currently pinned to GIACT's sandbox environment while live credentials are being issued — results are labelled Sandbox wherever they appear, are never billed, and deliberately do not move the trust score until the pin is lifted.
The CharityAPI checks (20–23) run for every workspace once the platform's CharityAPI key is in place;charityapi_verifications_enabled: false opts a workspace out. Sandbox workspaces use the CharityAPI test key when one is provided and the live key otherwise. They are route-to-review only — never an auto-reject and never a trust-score input — and a result from a test or emulated key is labelled as such and demoted to a warning before the free-checks gate aggregates.
Submission score formula
The pipeline computes a 0–100 trust score (higher = more trustworthy, lower = riskier) and persists it to Submission.score. The score starts at 100, deducts points for each failed, missing, or suspicious signal, and adds a small bonus for a strong signal:
| Signal | Deduction |
|---|---|
| Invalid EIN format | −25 |
| IRS 501(c)(3) not active | −30 |
| IRS lookup unavailable | −10 |
| Potential OFAC match | −40 |
| OFAC screening unavailable | −5 |
| No documents uploaded | −10 |
| One or more docs failed validation | −8 |
| Org trust score < 40 | −20 |
| Org trust score 40–69 | −10 |
| People screening (Klaw) group trust < 75 | −10 |
| People screening (Klaw) group trust < 55 | −20 |
| People screening (Klaw) fail or group trust < 35 | −30 |
| People screening (Klaw) inconclusive | −5 |
| Drift records | −5 each, max −20 |
| Org age < 2 years | −10 |
| Org age 2–4 years | −5 |
| Org age unknown (no founded year or IRS ruling date) | −3 |
| Strong online presence (web research) | +5 |
| Weak online presence (web research) | −8 |
| No verifiable online presence | −15 |
| Web research red flags | −5 each, max −20 |
| Applicant used VPN / anonymizing proxy | −12 |
| Applicant IP outside the US | −15 |
| Applicant IP changed during onboarding | −8 |
| Applicant location vs registered address — different country | −20 |
| Applicant location vs registered address — different state | −10 |
| Applicant location vs registered address — different city only | −4 |
| AI recommends reject | −25 |
| AI recommends review | −10 |
The result is clamped to 0–100. Note: because Stripe document gating makes uploads optional, a fully Stripe-verified applicant with no documents still receives the −10 "no documents uploaded" deduction on this internal score — it does not block submission. The location-vs-address rows are skipped when the applicant is on a VPN — the exit node's location measures nothing, and the VPN is already penalized.
Risk tiers (derived from trust score)
| Trust score | Risk tier |
|---|---|
| 80–100 | Low |
| 50–79 | Medium |
| 0–49 | High |
Trust score
A long-term 0–100 trust score is maintained on each NonProfitOrganization across all submissions. Reviewers see this prominently in the dashboard. Higher = more trusted.
Inputs, adding up to 100: IRS 501(c)(3) status (25), address consistency between IRS and business records (20), contact verification — the share of listed contacts verified, plus a verified contact authorized for banking changes (20), banking verification weighted by how recently the account was verified (20), and IRS data freshness (15). A drift penalty is then subtracted — high −20, medium −10, low −3 — and the result is clamped to 0–100 and bucketed into the same 80/50 tiers as the submission score. Prior submission history is not an input.
Vulnerability score (dark web & surface web)
Separate from the trust score, a dark web & surface web scan (powered by Klaw) checks the organization's own contact addresses against known breach corpora and returns a 0–100 vulnerability score per contact — higher = more exposed. The organization's headline vulnerability score is the worst-exposed contact, never the average, so one badly breached officer is not diluted by clean colleagues.
It answers a different question than the trust score — not “is this organization legitimate?” but “how much liability is attached to its people?” The same signal also feeds the people-screening contributor to the trust score above. It is exposed on the dark-web-scan VerificationCheck as raw_response.vulnerability_score plus per-contact breach detail (never a leaked credential value). Free on every plan and on by default; applicants can see their own exposure, while the composite trust score stays reviewer-only.
Drift detection
Detects changes since last verification — address moves, banking changes, contact email domain changes, IRS status changes, legal name changes. Each drift gets a severity (low/medium/high).
High-severity drift (e.g. a banking change) makes the payment check return a hold decision for the org until a reviewer clears the drift, under a default rule your risk config can switch off. The hold is a recorded decision: your own payment system is what sends or withholds the grant.
AI verification
Optional LLM-powered review (enable with features.ai_verification_enabled). Returns:
{
recommendation: "approve" | "review" | "reject",
confidence: number, // 0-100
reasoning: string, // Plain-text explanation
red_flags: string[], // Specific concerns
green_flags: string[], // Specific positives
document_checks: { // Fixed per-document-type verdicts, not keyed by document id
"501c3_letter": "valid" | "invalid" | "missing" | "pending",
"form_990": "valid" | "invalid" | "missing" | "pending",
"w9": "valid" | "invalid" | "missing" | "pending"
},
cross_check_results: {
address_match: "yes" | "no" | "partial",
irs_status_current: "yes" | "no" | "unknown",
ein_format_valid: "yes" | "no"
},
model_used: string // the model that answered, or the fallback
}Stored as an AIVerificationResult record. When the model omits a field the recommendation defaults to review and the confidence to 50. Outside sandbox mode the pipeline deducts 25 points for a reject recommendation and 10 for a review.
Stripe verifications
When Stripe is enabled, three live Stripe products are used:
| Product | Step | Verifies |
|---|---|---|
| Stripe Connect (Express) | 2 | EIN — verified by Stripe against IRS records — legal name and address. Stored as kind tax_id |
| Stripe Financial Connections | 3 | Bank account ownership matches the nonprofit name (0–1 match score). Kind bank_ownership |
| Stripe Identity | 4 | Authorized signer government ID (licence, passport or ID card) with a matching live-capture selfie. Kind identity |
The pipeline's stripe_tax_id step is not a fourth product: when Stripe verifications are enabled and the submission has no tax_id record yet, it reuses a fresh organization-level result if one exists and otherwise opens the same Connect Express account plus a hosted onboarding link.
Results stored on StripeVerification records. Platform admins and enterprise owners or admins — not reviewer-role members — can manually override a verification with a required justification; the record is marked manually_overridden and set to verified, the matching submission flag is set, and the action is audit-logged as stripe_verification_overridden.
Status state machine
Submissions move through these states:
draft
└─→ submitted (applicant clicks Submit)
├─→ in_review (reviewer claims)
│ ├─→ more_info_requested ──→ submitted (applicant re-submits)
│ ├─→ rejected (terminal)
│ └─→ pending_publish (reviewer approves)
│ └─→ published (terminal — live on enterprise site)
└─→ withdrawn (applicant cancels)
Reviewer decisions (approve, reject, request more info) are accepted from
submitted, in_review, or more_info_requested — a claim (in_review) is not required first.draft
Applicant started but hasn't submitted. Resumable via localStorage. Hidden from the review queue and the board by default — a "Show drafts" toggle reveals them. A reviewer who is not a view-only member can force-submit an abandoned draft into the queue from the submission page, which fires the same notifications as a normal submission, and an applicant with a nonprofit-portal account can withdraw a draft.
submitted
Final submission received. Triggers the automated verification pipeline, emails a submission_received confirmation to the submission's contact_email (with a one-time link to create a DAF Connect account pre-loaded with the submission), notifies the enterprise's support address, and sends a status-update email to the nonprofit's portal members. Demo submissions never send real email.
in_review
A reviewer has claimed the submission and is actively working on it. The nonprofit's portal members receive a status-update email; no decision email fires.
more_info_requested
Reviewer needs more info. Triggers decision_more_info email with reason. Applicant can edit and re-submit.
approved / pending_publish
Approval lands on pending_publish — meaning the reviewer approved but hasn't yet added the nonprofit to your live donation backend. Triggers the decision_pending_publish email. The approved value remains in the schema for older records, but nothing writes it today — an "approve" decision is stored as pending_publish on the server.
published
Reviewer has marked the nonprofit as live on your site. Triggers decision_published email. Terminal state.
rejected
Reviewer rejected. Triggers decision_rejected email with reason. Terminal state.
When emails fire
Applicant emails go to the nonprofit's contact_email, falling back to the applicant_email captured at the start of onboarding, and are logged in the EmailLog entity. Separately, a nonprofit that has portal accounts gets a plain status-update email (kind status_change) to each active member's login address on every applicant-visible status transition.
| Email kind | Trigger | How |
|---|---|---|
| submission_received | Applicant submits (status moves to submitted) | Automatic — once per submission, includes a one-time account-signup link |
| decision_pending_publish | Reviewer approves submission | Reviewer opt-in checkbox (defaults ON) |
| decision_rejected | Reviewer rejects submission | Reviewer opt-in checkbox (defaults ON) |
| decision_more_info | Reviewer requests more info | Reviewer opt-in checkbox (defaults ON) |
| decision_published | Reviewer marks as published | Automatic when reviewer clicks "Mark as Published" |
Email content
Email subject lines and body copy for the confirmation and decision emails are baked into the sendDecisionEmail backend function and personalized with your enterprise name and reviewer-provided reason text. Per-enterprise custom templates are not currently editable from the dashboard — contact your account manager if you need bespoke wording.
Webhooks overview
Webhooks are server-to-server HTTP callbacks fired the moment a submission's status changes — submitted, in_review, approved, rejected, published, and more. Use them to sync data into your CRM, fire Slack notifications, or kick off internal workflows without polling.
Configure your endpoint at /app/webhooks in the dashboard. Each delivery includes an HMAC-SHA256 signature so you can verify the request really came from DAF Connect.
dafconnect:complete postMessage event is client-side and only fires while the applicant has the iframe open. Webhooks are server-side and fire for every status change, including reviewer decisions made hours later.Register an endpoint
Go to /app/webhooks and:
- Enter your endpoint URL (must be
https://— plain http is rejected) - Optionally select specific events — leave all unchecked for "everything"
- Click Register webhook
- Copy the signing secret immediately — it's only shown once
- Click Send test ping to verify your endpoint responds with 2xx
You can also call the API directly:
await dafconnect.functions.invoke('registerWebhook', {
op: 'register',
enterprise_id: 'ent_abc123',
url: 'https://yoursite.com/api/dafconnect-webhook',
events: ['submitted', 'pending_publish', 'rejected'], // [] = subscribe to all
enabled: true
});
// Response includes webhook_secret ONLY on first registration:
// { ok: true, webhook_url, webhook_secret: 'whsec_...' }All operations
| op | Purpose |
|---|---|
| get | Returns current config (no secret) |
| register | Create or update the endpoint. Returns webhook_secret only when the enterprise has no signing secret yet. |
| enable | Resume delivery |
| disable | Pause delivery without losing config |
| remove | Delete config entirely |
| test | Send a test.ping event to verify your endpoint |
| rotate_secret | Issue a new signing secret (shown once); the previous key stops verifying immediately. URL and event filter are kept. |
| register_authorization | Set the per-submission authorization endpoint — url, enabled, fail_mode open | closed (see below) |
Available events
All events follow the format submission.<status>:
| Event | When it fires |
|---|---|
| submission.submitted | Applicant clicks Submit |
| submission.in_review | Reviewer claims the submission |
| submission.more_info_requested | Reviewer asks the applicant for clarification |
| submission.approved | Submission moved to approved (if used directly) |
| submission.pending_publish | Reviewer approves the submission (standard approval path) |
| submission.published | Reviewer marks the nonprofit as live on your giving page |
| submission.rejected | Reviewer rejects the submission |
| submission.withdrawn | Applicant withdraws their application |
| test.ping | Sent only when you click "Send test ping" |
| organization.verification_changed | Partner network API (opt-in): an organization's KYB, bank-account verification or IRS status changed |
| organization.bank_account_changed | Partner network API (opt-in): the organization's bank account changed — replaced, edited, unverified or locked; your destination tokens for it were revoked first |
| organization.eligibility_changed | Partner network API (opt-in): a ban, IRS revocation or suspension, confirmed sanctions match, or high drift appeared or cleared |
| destination_token.revoked | Partner network API (opt-in): DAF Connect revoked one of your destination tokens |
The four partner network events are delivered only when you check them by name — an empty subscription means every submission.* event, never a network event — and only while your enterprise's partner network API is enabled. Checking a network event never changes which submission.* events you receive. Their body has no submission_id; see Partner network API.
Payload format
Every webhook is a JSON POST to your endpoint with these headers:
| Header | Example |
|---|---|
| Content-Type | application/json |
| User-Agent | DAFConnect-Webhook/1.0 |
| X-DAFConnect-Event | submission.pending_publish |
| X-DAFConnect-Delivery-Id | evt_a3f1... |
| X-DAFConnect-Signature | t=1700000000,v1=abc123def456... |
{
"id": "evt_a3f1c8d9-...",
"event": "submission.pending_publish",
"created_at": "2026-05-26T15:30:00.000Z",
"enterprise_id": "ent_abc123",
"submission_id": "sub_xyz789",
"livemode": true,
"data": {
"submission_ref": "sub_xyz789",
"status": "pending_publish",
"previous_status": "in_review",
"nonprofit_id": "np_abc",
"nonprofit_name": "Acme Nonprofit Inc",
"decision_reason": "All verification checks passed. Approved for FY2026.",
"decided_by": "reviewer@yoursite.com",
"decided_at": "2026-05-26T15:29:55.000Z",
"submitted_at": "2026-05-26T10:00:00.000Z",
"published_at": null,
"score": 88
}
}score is the submission trust score (0–100, higher = more trustworthy) — the same value persisted to Submission.score.
livemode is false on every submission.* and test.ping event from a sandbox, emulation or demo workspace, whose verifications run against fixtures. It is part of the signed body. A production receiver should verify the signature and then reject any event with livemode: false.
HTTP <status>(timeouts as Timeout after 10000ms, unreachable hosts as Connection failed) — so answer 2xx directly from the registered URL. The first 500 characters of your response body are kept in the delivery log for debugging.Verifying signatures
Every request includes an X-DAFConnect-Signature header of the form t=<timestamp>,v1=<hex_signature>. To verify:
- Extract
t(timestamp) andv1(signature) - Concatenate
`${t}.${rawBody}`(timestamp, dot, raw request body) - Compute HMAC-SHA256 of that string using your
webhook_secret - Compare against
v1in constant time - Reject events older than 5 minutes to prevent replay attacks
import crypto from 'node:crypto';
function verifyDafConnectSignature(rawBody, headerValue, secret) {
if (!headerValue) return false;
const parts = Object.fromEntries(
headerValue.split(',').map(p => p.split('='))
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
// Replay protection — reject anything older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (Math.abs(age) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
}Example handler
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.DAFCONNECT_WEBHOOK_SECRET;
// IMPORTANT: use express.raw so we can verify against the raw body
app.post('/api/dafconnect-webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.header('X-DAFConnect-Signature');
const raw = req.body.toString('utf8');
if (!verifyDafConnectSignature(raw, sig, SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(raw);
console.log('Got event:', event.event, 'for', event.submission_id);
// Handle the event
switch (event.event) {
case 'submission.submitted':
await pushToCRM(event.data);
break;
case 'submission.pending_publish':
await notifySalesSlack(event.data);
break;
case 'submission.published':
await markActiveInDonationPage(event.data.nonprofit_id);
break;
case 'submission.rejected':
// Log decision_reason for compliance records
break;
}
// ALWAYS respond 2xx fast — do heavy work after
res.status(200).send('ok');
}
);
app.listen(3000);from flask import Flask, request, abort
import hmac, hashlib, time, os
app = Flask(__name__)
SECRET = os.environ['DAFCONNECT_WEBHOOK_SECRET']
@app.route('/api/dafconnect-webhook', methods=['POST'])
def handle_webhook():
raw = request.get_data(as_text=True)
header = request.headers.get('X-DAFConnect-Signature', '')
parts = dict(p.split('=') for p in header.split(','))
t, v1 = parts.get('t'), parts.get('v1')
if not t or not v1:
abort(401)
if abs(int(time.time()) - int(t)) > 300:
abort(401, 'replay')
expected = hmac.new(
SECRET.encode(),
f"{t}.{raw}".encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, v1):
abort(401)
event = request.get_json()
# ... handle event ...
return 'ok', 200Testing & debugging
Test endpoint
Use the Send test ping button in /app/webhooks, enabled once an endpoint is registered, to dispatch a test.ping event. The test bypasses your event subscription filter, but not the Paused state: a paused endpoint answers skipped and logs nothing, so resume it first. With no endpoint registered the call returns 400.
Delivery log
Every webhook attempt is logged in the dashboard with HTTP status, response snippet, duration, and any error message. Use this to debug failed deliveries without grepping your server logs.
Local development
The endpoint must be a public https:// URL. As an SSRF safeguard, the validator rejects localhost, loopback, and private or internal hosts (e.g. 127.0.0.1, 10.0.0.0/8, 192.168.0.0/16) — you cannot register a local address directly. Use ngrok or cloudflared to expose your local server over a public HTTPS URL for testing.
Retries
Failed submission.* deliveries are not automatically retried in the current version. The partner network events (organization.*, destination_token.revoked) are: up to 10 attempts within 24 hours, with exponential backoff and full jitter, and the same id on every attempt. Either way your endpoint should be idempotent and tolerant of duplicates — use the id field for deduplication.
Changing the secret
The signing secret is shown exactly once, when it is generated or rotated. To issue a fresh one, use Rotate secret on /app/webhooks (op rotate_secret): the new value is displayed once, the previous key stops verifying immediately, and your URL and event filter are unchanged. The rotation is audit-logged without the secret value.
One-time intake links
An intake link is a custom, single-use URL that opens your DAF Connect application form exactly once. It stays valid until the applicant submits — then it's consumed, and any later visit to the URL renders a 404. Use it to hand a specific nonprofit a private link (per applicant, no shared public embed) that can't be forwarded or reused after they're done.
The link opens the same onboarding flow as the iframe embed, served from /intake/<token> where <token> is a crypto-random value minted server-side. Links expire automatically 30 days after they're minted if unused.
Two ways to mint a link
| Method | Who | How |
|---|---|---|
| Dashboard button | A signed-in owner or admin | Click Generate intake link on /app/webhooks — the one-time URL appears with a Copy button |
| Server-to-server webhook | Your enterprise's backend | POST to your branded /hooks/v1/intake-links endpoint, authenticated with your webhook signing secret |
Mint from the dashboard (SDK)
The dashboard button uses your signed-in session — no secret needed. Owners and admins only.
const { data } = await dafconnect.functions.invoke('mintIntakeLink', {
enterprise_id: 'ent_abc123',
external_ref: 'your-applicant-id-123' // optional
});
// data.url → https://daf-connect.com/intake/ily_live_...
// Drop data.url into an iframe or send it to the applicant.Mint from your backend (server-to-server)
Have your own server mint a link per applicant with no human in the loop. POST to your branded webhook endpoint, authenticating with the same webhook_secret you manage on the Webhooks page — never expose it in client-side code.
curl -X POST \
https://daf-connect.com/hooks/v1/intake-links \
-H 'Content-Type: application/json' \
-d '{
"enterprise_id": "ent_abc123",
"webhook_secret": "YOUR_WEBHOOK_SECRET",
"external_ref": "your-applicant-id-123"
}'{
"success": true,
"url": "https://daf-connect.com/intake/ily_live_...",
"link_id": "il_...",
"status": "active",
"expires_at": "2026-06-25T15:30:00.000Z",
"external_ref": "your-applicant-id-123"
}| Field | Meaning |
|---|---|
| url | The one-time link — /intake/<token>. Embed it in an iframe or send it directly. Once you have configured allowed domains, an embedded link renders only on those sites, like the iframe embed. |
| link_id | Short reference for this link (shown in the dashboard list & audit log) |
| status | active until submitted, then consumed; expired after 30 days unused |
| expires_at | ISO timestamp — 30 days from mint |
| external_ref | Echoed straight back from your request for reconciliation with your own records |
external_ref (e.g. your internal applicant ID) is stored on the link and returned verbatim, so you can match the minted link — and later the resulting submission — back to the applicant in your own system./hooks/v1/intake-links is your own branded webhook. It forwards to our backend behind the scenes, so the URL your systems integrate against carries no vendor or app identifiers — just your domain. The same signing secret authenticates a sibling endpoint, POST https://daf-connect.com/hooks/v1/onboarding-invitations, which mints a tokenized onboarding invitation for a specific contact (body: enterprise_id, webhook_secret, email).Partner network API
A signed, server-to-server API for DAF platforms: find organizations, read DAF Connect's own organization-level verification, dry-run grant eligibility, check electronic-delivery capability, and mint or revoke destination tokens — opaque, single-use references to an organization's verified bank account. A token is not a payment instruction and can never be exchanged for bank digits; DAF Connect does not move money. The API is enabled per enterprise by DAF Connect.
| Action | What it returns |
|---|---|
| search_organization | Organizations by name or EIN, with network_status (verified / unverified / ineligible / unknown) |
| get_organization | Public registry data for one organization (by org_ref or EIN) |
| get_verification_status | Organization and bank-account verification, IRS, sanctions / drift summaries, restrictions, and your own submission for it |
| get_grant_eligibility | A read-only dry run of the eligibility rules: decision + reason codes. Writes nothing |
| get_delivery_capabilities | Whether a destination token can be minted, and the masked bank hint (last 4 only) |
| mint_destination_token | A dct_live_… token scoped to one organization, grant reference and amount cap; requires X-DAFConnect-Idempotency-Key |
| revoke_destination_token | Revokes one of your tokens (idempotent) |
| ping | Credential and clock check |
POST https://daf-connect.com/hooks/v1/network
Content-Type: application/json; charset=utf-8
X-DAFConnect-Enterprise: <your enterprise id>
X-DAFConnect-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(api_secret, t + "." + raw body)>
{"api_version":"2026-10-01","action":"ping","request_id":"<uuid, new per request>","params":{}}dcsk_… API secret on /app/webhooks (owners and admins, second factor required). It is shown once; rotating keeps the previous secret valid for 24 hours. Requests more than 300 seconds from our clock, with a reused request_id, or with a bad signature are refused. Sandbox workspaces are answered from fixture organizations only and mint dct_test_… tokens.React component
import { useEffect, useRef, useState } from 'react';
export function DafConnectEmbed({ publicKey, onComplete }) {
const containerRef = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const iframe = document.createElement('iframe');
iframe.src = `https://daf-connect.com/embed/${publicKey}`;
iframe.style.cssText = 'width:100%;height:800px;border:none;border-radius:12px;';
iframe.allow = 'camera; microphone; clipboard-write';
containerRef.current.appendChild(iframe);
const handler = (e) => {
if (!e.data?.type?.startsWith('dafconnect:')) return;
switch (e.data.type) {
case 'dafconnect:loaded':
setLoading(false);
break;
case 'dafconnect:resized':
iframe.style.height = (e.data.height + 20) + 'px';
break;
case 'dafconnect:complete':
onComplete?.(e.data.submissionRef);
break;
}
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
iframe.remove();
};
}, [publicKey]);
return (
<div className="relative">
{loading && <div className="absolute inset-0 flex items-center justify-center">Loading…</div>}
<div ref={containerRef} />
</div>
);
}Vanilla JavaScript
function mountDafConnect({ container, publicKey, onComplete, onSubmitted }) {
const iframe = document.createElement('iframe');
iframe.src = `https://daf-connect.com/embed/${publicKey}`;
iframe.style.cssText = 'width:100%;height:800px;border:none;border-radius:12px;';
iframe.allow = 'camera; microphone; clipboard-write';
container.appendChild(iframe);
const handler = (e) => {
if (!e.data?.type?.startsWith('dafconnect:')) return;
if (e.data.type === 'dafconnect:resized') {
iframe.style.height = (e.data.height + 20) + 'px';
} else if (e.data.type === 'dafconnect:submitted') {
onSubmitted?.(e.data.submissionRef);
} else if (e.data.type === 'dafconnect:complete') {
onComplete?.(e.data.submissionRef);
}
};
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
iframe.remove();
};
}
// Usage
mountDafConnect({
container: document.getElementById('dafconnect'),
publicKey: 'pk_abc123',
onComplete: (ref) => alert('Done! ' + ref)
});Modal wrapper
See Modal pattern in the Embedding section above.
Analytics tracking
Track the onboarding funnel in your analytics platform:
window.addEventListener('message', (e) => {
if (!e.data?.type?.startsWith('dafconnect:')) return;
switch (e.data.type) {
case 'dafconnect:loaded':
analytics.track('Onboarding Started');
break;
case 'dafconnect:stepChanged':
analytics.track('Onboarding Step Viewed', {
step: e.data.step,
total: e.data.totalSteps,
progress_pct: Math.round((e.data.step / e.data.totalSteps) * 100)
});
break;
case 'dafconnect:submitted':
analytics.track('Onboarding Submitted', { ref: e.data.submissionRef });
break;
case 'dafconnect:complete':
analytics.track('Onboarding Completed', { ref: e.data.submissionRef });
break;
case 'dafconnect:error':
analytics.track('Onboarding Error', { code: e.data.code, message: e.data.message });
break;
}
});Prefilling fields
When the applicant is already logged into your site, prefill known fields:
const iframe = document.querySelector('#dafconnect iframe');
window.addEventListener('message', (e) => {
// Wait for iframe to be ready before sending prefill
if (e.data?.type === 'dafconnect:loaded') {
iframe.contentWindow.postMessage({
type: 'dafconnect:prefill',
data: {
legal_name: currentUser.org_name,
contact_name: currentUser.full_name,
contact_email: currentUser.email,
website: currentUser.org_website
}
}, '*');
}
});Embed test page
A small HTML test harness for verifying your iframe loads, the dafconnect:resized auto-resize handshake works, and what postMessage events the embed is actually emitting. Useful when debugging "why isn't my iframe sizing correctly?" before integrating into your real site.
What you get
- An iframe pointing at your
/embed/:public_keyURL (editable in the top bar) - Live width / height controls — type a pixel value or toggle "auto-fit width"
- Status dots showing whether the iframe loaded and whether auto-resize is working
- A black log panel that prints every postMessage event received, including origin & payload — so you can immediately see if the iframe is silent, sending from the wrong origin, or sending the wrong shape
How to use
- Click Download above to save the file
- Open the file in your browser — just double-click it (Mac/Linux) or right-click → Open With → your browser (Windows). The page works straight from
file://so no web server is needed. - Edit the
iframe srcinput at the top to point at your enterprise's embed URL — e.g.https://daf-connect.com/embed/YOUR_PUBLIC_KEY— then click Reload - Watch the dots:
- iframe load green = the iframe responded
- auto-resize green = received a
dafconnect:resizedmessage with a valid height
- Scroll through the onboarding flow — every step change, toast, and resize event will appear in the log
file://, which is treated as a separate origin, so the embed will refuse to render. An empty allowed_domains list does NOT mean "allow all" — it authorizes no sites at all, so the form renders nowhere. Host the file on a domain you've added to the allowlist (a staging subdomain works, since subdomains of a listed domain are included) to test the enforcement end to end.- "iframe load" dot stays grey — the embed URL is wrong, or your domain isn't on the allowlist (you'll see the "Embed not authorized" screen inside the iframe)
- "auto-resize" dot stays grey — postMessage events ARE being sent (check the log) but the height payload is missing. Verify the embed isn't blocked by browser extensions.
- Log shows messages from a non-daf-connect origin — those will appear in orange and be ignored for resizing. This is expected for messages from browser extensions, ads, etc.
AES-256-GCM encryption
All sensitive fields (EIN, banking, signer ID) are encrypted at rest with AES-256-GCM using a unique per-enterprise key. That key is generated automatically the first time your enterprise encrypts anything and lives in a separate, service-role-only secrets vault — it is never stored on the Enterprise record your team can read, and it is never returned by any API, data export included. Each ciphertext includes a fresh 12-byte IV prepended to the encrypted bytes. Decryption is gated twice: row-level security limits it to members of the originating enterprise, and the reviewer's session must hold a verified second factor — platform admins included — or the request returns step_up_required. Every decrypt is audit-logged. Government-ID scans are held in private storage and served only through short-lived signed URLs.
Data retention
The enforceRetentionPolicies job runs daily and enforces these windows:
| Data | Retention | Then |
|---|---|---|
| Rejected / withdrawn submissions | 3 years from last activity (default) | Soft-archived, then hard-deleted (with all child records) 30 days later |
| Approved / published submissions | Never auto-deleted | — |
| Audit logs | 5 years | Trimmed |
| Email logs | Tied to submission (5-year hard cap) | Cascaded on submission delete |
| Stripe webhook events | 5 years | Trimmed |
The 3-year default can be overridden per enterprise (1 year / 3 years / 7 years / Indefinite). Archived submissions remain inspectable by admins during the 30-day grace period. Owners and admins change the window themselves at /app/settings under Data retention; the value is stored as feature_flags.retention_policy and picked up on the next daily run.
Row-level security
Every entity has RLS enforcing:
- Nonprofit applicants have no read grant on submission rows at all — the nonprofit portal serves a safe subset of their own organization's submissions through server functions, so reviewer-internal fields never reach an applicant
- Enterprise members see only their enterprise's submissions
- Admins see everything (for support purposes)
- Demo tenants get no special read grant — demo rows obey the same tenant rules, and the public demo reads a scrubbed subset through dedicated server functions
Cross-tenant access attempts are blocked at the database level — not just the UI.
Domain enforcement
The iframe parses document.referrer and window.location.ancestorOrigins to determine the parent's origin. If it isn't on your enterprise's allowed_domains, the iframe refuses to render and emits an error event.
Tamper-evident audit log
Every data-changing action lands in AuditLog with the actor, IP, user agent, before and after state, and timestamp. Each row is HMAC-sealed and hash-linked to the row before it (seq, prev_hash, entry_hash), so an edited, deleted, or reordered entry is detectable. Rows are written only by backend functions — no tenant member or platform admin can create, alter, or remove one through the API — and a platform-admin verifier (verifyAuditChain) walks the chain and reports the first break. Entries are retained for five years.
Data export
Workspace owners and admins can use Settings → Workspace data export to prepare and download JSON. The backend function exportEnterpriseData requires a verified second factor and accepts an active owner/admin membership or a platform enterprises write grant. Every request is rate-limited and audit-logged. op: 'manifest' returns the supported record types, a creation cutoff, exclusions and bounded count estimates; op: 'export' returns a page. Callers pass the same created_before cutoff and follow has_more and next_offset until an empty terminal page. Each next request also carries the last record ID as previous_id; records are ordered by ID and the server verifies the terminal ID against the table's tail. Short pages are not exhaustion, and all positive manifest counts are lower bounds, not export limits. Secrets, account numbers, access links, audit state snapshots and raw provider payloads are withheld. Private file contents, shared nonprofit profiles and platform records are excluded; encrypted values stay encrypted. Collection is not an atomic snapshot or a restorable backup, and redacted audit records cannot verify the original hash chain. The browser stops at 50 MiB or 500 pages and marks interrupted collections incomplete, including the completed record types and error in the file. Contact support for larger exports, private documents or subject-access requests. This function is not part of the public API-key surface.
Transport security & headers
Every response carries a Strict-Transport-Security header (two years, including subdomains) and a Content-Security-Policy generated from a single source and served as a header. Outbound legs that carry applicant data — webhook deliveries, the per-submission authorization call, and bank-verification relays — are https:// only, refuse redirects, and reject private or loopback hosts.
Rate limiting & firewall
Public and privileged endpoints call a shared request firewall before doing any work. It inspects payloads for injection, SSRF, and leaked-secret patterns, applies a per-tenant rate ceiling (at most 600 requests per minute, tracked in a durable ten-minute window), and enforces a platform IP allow and deny list in which deny wins. A blocked request returns 403 and is recorded in the audit log with its endpoint and observed IP.
Entity reference
Core data model entities you'll encounter:
| Entity | Purpose |
|---|---|
| Enterprise | Your organization config — branding, domains, features |
| NonProfitOrganization | The applicant — IRS data, contact info, trust score |
| Submission | A single application from a nonprofit to an enterprise |
| SubmissionDocument | Files uploaded during onboarding |
| VerificationCheck | Result of one automated check (e.g. OFAC) |
| StripeVerification | Result of a Stripe verification call |
| AIVerificationResult | LLM-powered approve/review/reject recommendation |
| DriftRecord | A detected change since last verification |
| TrustEvent | Log entry for trust score changes |
| EmailLog | Record of every email sent to an applicant |
| InternalNote | Private reviewer notes (submission or org-scoped) |
| AuditLog | Every data-changing action — HMAC-sealed, hash-linked, written only by the server |
| SubmissionStatusHistory | Every status transition on a submission (the applicant-visible timeline) |
| IntakeLink | One-time intake link — active until consumed, expired after 30 days unused |
| WebhookDelivery | One webhook delivery attempt: event, HTTP status, response snippet, duration |
| KlawVerification | People-screening result from the reviewer-side verification pipeline |
| EnterpriseSecret | Service-role-only vault for the tenant encryption key and signing secrets — no member can read it |
| EnterpriseMembership | Maps users to enterprises with roles (owner/admin/reviewer/viewer/financial_manager). Financial managers can manage billing, browse workspace submissions and nonprofits, and submit grant requests. Their sidebar highlights billing and settings; settings changes and other restricted operations still require the appropriate owner/admin or reviewer permissions. |
Troubleshooting
| Problem | Solution |
|---|---|
| Iframe shows "Embed not authorized" | Add your domain to allowed_domains in /app/embed |
| "Enterprise not found" | Verify your public_key — check /app/embed in dashboard |
| Camera/identity step fails | Ensure iframe has allow="camera; microphone" and page is HTTPS |
| Resize event never fires | ResizeObserver requires modern browser — check polyfill |
| Submission stuck in "draft" | Applicant didn't complete step 11 (Submit). Reviewer can force-submit |
| "unauthorized_origin" error | No allowed domains configured yet (the list defaults to closed), the parent page domain is not on the allowlist, or the parent origin could not be determined (no referrer and no ancestorOrigins) |
| "Request blocked: rate_limit_exceeded" (HTTP 403) | The request firewall caps each endpoint per minute per client IP and per signed-in user — 60/min for the public enterprise lookup, submit and encrypt calls, 30/min for decrypt — with a 10-minute sustained window. Wait about a minute and retry; do not poll intake endpoints in a loop |
| Verification pipeline not running | Check /app/audit — pipeline runs async after status=submitted |
| Decryption errors in reviewer dashboard | Reviewer not a member of submission's enterprise |
Support
Need help?
Contact your DAF Connect account manager or email information@daf-connect.com with your enterprise slug and the submission reference (if applicable).