On this page
Documentation
v2.4 · Last updated Sep 5, 2026

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.

Getting Started

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.

Getting Started

How it works

  1. Configure your enterprise in the DAF Connect dashboard — branding, required fields, custom questions, allowed domains.
  2. Embed the iframe on your site using your public key.
  3. 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.
  4. Verification pipeline runs automatically — IRS, OFAC, exclusions, web research, document scans, AI review, trust score. First results land in seconds.
  5. Your reviewers approve, reject, or request more info from the reviewer dashboard.
  6. Email notifications fire to the applicant — a receipt when they submit, then on reviewer decisions (approve / reject / more info / published).
  7. Your site receives postMessage callbacks at every step to update UI in real time.
Getting Started

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>
Getting Started

Requirements

RequirementWhy
HTTPS in productionRequired for camera/microphone access (Stripe Identity)
Modern browser (ES6+)Iframe uses postMessage, ResizeObserver, async/await
Public key from dashboardIdentifies your enterprise — found at /app/embed
Domain on allowlistSecurity — only listed parent domains can embed the iframe
camera/microphone allow attributeRequired for Stripe Identity ID verification
Backend not requiredAll verification & state happens server-side at DAF Connect, on Base44 — the platform this application runs on
Embedding

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

AttributeRecommended valueNotes
src/embed/{your_public_key}Required
allowcamera; microphone; clipboard-writeRequired for Stripe Identity
titleNonprofit onboardingFor accessibility
loadinglazyOptional — defer until in viewport
referrerpolicyno-referrer-when-downgradeUsed by domain allowlist
Embedding

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>
Hosted vs Embed
The hosted page is identical to the iframe but runs full-screen — perfect when you don't control the parent page (e.g. sending a link in email). Opened directly, it always works. If you put it in an iframe it enforces the same domain allowlist the embed route does, so a blocked embed can't be reopened as a hosted link inside the same page. A blocked hosted page only shows a "This form can't be displayed here" screen, though — unlike /embed/ it emits no dafconnect:error, and it never emits dafconnect:resized either. Frame /embed/ when your page needs those events.
Embedding

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.

An empty list means nobody can embed
A new enterprise starts with no allowed domains, and an empty list does not mean "allow all" — it authorizes no third-party site at all, so the iframe renders on none of them until you add one. Only the dashboard's own Embed Manager preview, and the page opened directly, still work. The check also fails closed when the parent origin can't be determined (no referrer and no ancestorOrigins), so don't set referrerpolicy="no-referrer" on the iframe or the host page. Both cases emit dafconnect:error with code unauthorized_origin.
Enterprise.allowed_domains
{
  "allowed_domains": [
    "yoursite.com",
    "staging.yoursite.com",
    "localhost"
  ]
}
Subdomain matching
An entry of yoursite.com also matches www.yoursite.com and app.yoursite.com. Don't include the protocol or trailing slash.
Fails closed
An empty 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.

Embedding

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';
  }
});
PostMessage API

Events

All communication between the iframe and your parent page uses the Window.postMessage API.

EventDirectionFired when
dafconnect:loadedoutIframe finishes initial load
dafconnect:stepChangedoutUser navigates to a new step (1–11)
dafconnect:resizedoutIframe content height changes
dafconnect:submittedoutSubmission persisted (fires first)
dafconnect:completeoutThank-you screen renders — the one to listen for
dafconnect:toastoutNon-critical notification
dafconnect:erroroutCritical error blocking continuation
dafconnect:prefillinYou 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.

Payload
{
  "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.

Payload
{
  "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.

Payload
{
  "type": "dafconnect:resized",
  "height": 1280   // pixels
}
Note the spelling
The event is 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.

Payload
{
  "type": "dafconnect:submitted",
  "submissionRef": "sub_abc123xyz789"
}
Store 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.

Payload from iframe → parent
{
  "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:

What the iframe does (for reference)
// 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:

Minimal listener
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:

1. Redirect to a thank-you page
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;
  }
});
2. Close the modal & show a confirmation
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);
  }
});
3. Refresh a partner list / dashboard
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);
  }
});
4. Fire your own analytics event
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'
    });
  }
});
5. React — state-based with cleanup
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 />;
}
Verify the origin in production
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
When does this fire vs dafconnect:submitted?
dafconnect:submitted fires the millisecond the applicant clicks Submit, before the thank-you screen renders. dafconnect:complete fires once the thank-you screen is shown and the submission is fully persisted. For most integrations, listen for complete — it's the safer signal.
Server-side notifications
The postMessage callback is client-side only — it only fires while the applicant has the iframe open. For server-to-server callbacks on every status change (including reviewer decisions made hours later), see Server Webhooks.

dafconnect:toast

Non-critical UX notifications fired after submission. You can ignore these or surface them in your own toast system.

Payload
{
  "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.

Payload
{
  "type": "dafconnect:error",
  "code": "unauthorized_origin" | "submit_failed" | "verification_failed",
  "message": "Human-readable error description"
}

Error codes

CodeMeaningAction
unauthorized_originFraming 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_failedSubmission API call failedCheck console, retry
verification_failedThe server-side security check (step 7) refused the applicant; the flow is lockedNot retryable by the applicant — they should contact the enterprise
When an enterprise can't be found for the given public key, the iframe renders an "Embed not authorized" screen inline — it does not emit a separate error event. Verify your 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.

Send AFTER load
Wait for dafconnect:loaded before sending prefill, otherwise it'll arrive before the iframe's listener is attached.
Configuration

Enterprise settings

All onboarding behaviour is controlled by your Enterprise record, configurable at /app/branding, /app/embed, and /app/settings.

FieldTypeDescription
namestringDisplay name shown in iframe header & emails
slugstringURL-safe identifier
logo_urlstringLogo shown in iframe header (200×50 recommended)
primary_colorstringProgress rail + header brand square (when no logo) in the wizard; 4px top bar on verification-link pages. Hex, e.g. #006044
accent_colorstringContinue/Submit CTA, verification "Start" buttons and success-screen icon. Hex, e.g. #006990
intro_copystringWelcome text on step 1
success_messagestringThank-you screen message
allowed_domainsstring[]Domain allowlist for iframe embedding
custom_fieldsarrayCustom questions added to step 9 (Additional questions)
featuresobjectFeature 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_flagsobjectWhite-label toggle (hide_daf_branding) and the data-retention override (retention_policy)
Configuration

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.

Configuration

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.

Need optional onboarding steps for your specific program? Contact your account manager — we can configure this on the backend.
Configuration

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

TypeRenders as
textSingle-line input (default if type is omitted)
textareaMulti-line text box (3 rows)
dropdownSelect dropdown — requires options array
yes_noTwo radio buttons (Yes / No)
Each field requires a unique key, a label, and optionally required: true and placeholder.
Configuration

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;
  }
});
Configuration

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
  }
}
FlagLocationEffect
stripe_verifications_enabledfeaturesStep 2/3/4 use Stripe (real verification) instead of manual entry
ai_verification_enabledfeaturesRuns 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_enabledfeaturesAdds the four GIACT checks (sanctions, business identity, email intelligence, bank verification) to the pipeline — off by default
charityapi_verifications_enabledfeaturesCharityAPI.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_enabledfeaturesDark-web & surface-web exposure scan of contact addresses (Klaw) — on unless set to false
auto_approve_enabledfeaturesAutomatically approve submissions whose trust score reaches auto_approve_threshold — off by default
auto_approve_thresholdfeaturesTrust score a submission must reach to auto-approve (default 80)
verification_cache_daysfeaturesReuse a passed signer-identity verification for this many days instead of re-running (and re-charging) it; unset = reuse any prior pass
share_graph_namesfeaturesWhen 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_brandingfeature_flagsRemoves "Secured by DAF Connect" footer
Onboarding Flow

Flow overview (11 steps)

The applicant moves through these steps:

#StepCollectsRequired?
1OrganizationLegal nameAlways
2Company verifyEIN, address, contact (via Stripe Connect or manual)Always
3Bank accountRouting + account (via Stripe FC or manual)Always
4Signer identityGovernment ID (via Stripe Identity or manual)Always
5MissionMission statement, NTEE code, founded year, websiteConfig-driven
6Leadership contacts2–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
7FinancialsRevenue / expenses / total assets for the last 3 fiscal years, total liabilities (most recent year), mission area (NTEE)Optional
8Custom questionsEnterprise-specific questionsPer-field
9DocumentsIRS letter, Form 990, bylaws, etc.Per-doc-type
10ReviewRead-only summary with edit linksAlways
11SubmitFinal confirmationAlways

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.

Free checks gate (Stripe rail)
On the Stripe rail, Continue on step 1 creates the draft and runs the free checks — EIN format, IRS 501(c)(3) status, OFAC screening, a document-validity check and a look-alike charity-name signal — before any paid Stripe step unlocks. Steps 2, 3 and 4 stay locked until the outcome is 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.
Onboarding Flow

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:

ModeBehaviour
Stripe ONLive Stripe Connect, Financial Connections & Identity flows. Bank ownership is verified against legal name with a match score. Identity verifies government ID.
Stripe OFFManual entry — applicant types bank/EIN, uploads a government ID file. Reviewer verifies manually.
Onboarding Flow

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 ID
  • banking_account_name — Account holder name
  • banking_routing — Routing number
  • banking_account — Account number
  • banking_bank_name — Bank name
  • signer_id_file_url — Signer ID document URL
  • signer_id_file_name — Signer ID filename
The per-enterprise encryption key is auto-generated on first use and stored in a dedicated server-side secret vault that only backend functions can reach — it never appears on any record your dashboard (or any tenant member) can read.
Onboarding Flow

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 keyLabelRequiredMax filesFormats
501c3_letterIRS 501(c)(3) Determination LetterYes*1PDF, JPG, PNG
w9IRS Form W-9Yes*1PDF only
form_990IRS Form 990 (most recent 3 fiscal years)Yes*3PDF only
audited_financialsAudited Financial StatementsOptional3PDF only
insuranceCertificate of InsuranceOptional2PDF, JPG, PNG
board_listBoard Resolution Authorizing BankingOptional1PDF 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.

* Documents are optional when Stripe verifications all pass
When Stripe mode is enabled and all three Stripe verifications come back clean — company / EIN (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.
Onboarding Flow

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.

Verification Pipeline

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.

Verification Pipeline

23 automated checks

#CheckSourceWhat it does
1EIN formatInternal regexValidates XX-XXXXXXX pattern
2IRS 501(c)(3)IRS BMF / ProPublicaConfirms active 501(c)(3) status
3OFAC screeningUS Treasury SDN listSanctions list match check (org + officers/board)
4Exclusions screeningSAM.gov + HHS OIG LEIEFederal debarment & healthcare exclusion match — always route-to-review, never auto-reject
5Document validationInternal MIME/size checkPipeline-side recheck (≤25MB, allowed MIME types)
6Virus scanningVirusTotalEvery uploaded document; per-doc status in the reviewer panel
7Form 990 financialsProPublicaPulls the filing history that feeds financial forensics
8Web researchLLM + live webOnline presence strength and red flags
9Applicant IP intelligenceInternalVPN detection, geolocation vs stated address, IP changes mid-application
10Drift detectionInternalDetects changes since last submission
11Entity-graph edgesInternalShared officers, addresses, and bank accounts across applicants (ring detection)
12Org trust scoreInternal model0–100 org trust score based on history & drift
13AI verification (opt)LLMApprove/Review/Reject recommendation with reasoning
14Stripe Tax ID (opt)StripeValidates EIN matches IRS via Stripe
15GIACT sanctions screen (opt)GIACT gOFAC (LSEG Risk Intelligence)Independent second sanctions/watchlist rail over org + officers + board
16GIACT business identity (opt)GIACT gIdentify BusinessBusiness identity + live IRS TIN gateway match on the org
17GIACT email intelligence (opt)GIACT ESIEmail authenticity, age, and fraud-history signals on the applicant email
18GIACT bank verification (opt)GIACT gVerify / gAuthenticate / Open BankingAccount open/active status + ownership match — typed-in details verified post-submit, or connected live through GIACT’s hosted Open Banking widget
19Submission trust scoreInternal heuristic0–100 composite (higher = more trustworthy), saved to Submission.score
20IRS Pub. 78 listingCharityAPI.org (IRS Pub. 78)Public-charity listing for the EIN — needs CharityAPI; route-to-review only, never moves the score
21IRS registry matchCharityAPI.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
22IRS foundation typeCharityAPI.org or ProPublica (IRS BMF)Foundation / deductibility / status codes → DAF-eligibility posture (private foundations and Type III non-FI supporting orgs route to review)
23IRS registries comparedProPublica + 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.

Verification Pipeline

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:

SignalDeduction
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 scoreRisk tier
80–100Low
50–79Medium
0–49High
Verification Pipeline

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.

Verification Pipeline

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.

Verification Pipeline

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.

Verification Pipeline

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.

Verification Pipeline

Stripe verifications

When Stripe is enabled, three live Stripe products are used:

ProductStepVerifies
Stripe Connect (Express)2EIN — verified by Stripe against IRS records — legal name and address. Stored as kind tax_id
Stripe Financial Connections3Bank account ownership matches the nonprofit name (0–1 match score). Kind bank_ownership
Stripe Identity4Authorized 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.

Submission Lifecycle

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.

Email Notifications

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 kindTriggerHow
submission_receivedApplicant submits (status moves to submitted)Automatic — once per submission, includes a one-time account-signup link
decision_pending_publishReviewer approves submissionReviewer opt-in checkbox (defaults ON)
decision_rejectedReviewer rejects submissionReviewer opt-in checkbox (defaults ON)
decision_more_infoReviewer requests more infoReviewer opt-in checkbox (defaults ON)
decision_publishedReviewer marks as publishedAutomatic when reviewer clicks "Mark as Published"
Email Notifications

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.

Server Webhooks

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.

Webhooks vs postMessage
The 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.
Server Webhooks

Register an endpoint

Go to /app/webhooks and:

  1. Enter your endpoint URL (must be https:// — plain http is rejected)
  2. Optionally select specific events — leave all unchecked for "everything"
  3. Click Register webhook
  4. Copy the signing secret immediately — it's only shown once
  5. Click Send test ping to verify your endpoint responds with 2xx

You can also call the API directly:

Register via SDK
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

opPurpose
getReturns current config (no secret)
registerCreate or update the endpoint. Returns webhook_secret only when the enterprise has no signing secret yet.
enableResume delivery
disablePause delivery without losing config
removeDelete config entirely
testSend a test.ping event to verify your endpoint
rotate_secretIssue a new signing secret (shown once); the previous key stops verifying immediately. URL and event filter are kept.
register_authorizationSet the per-submission authorization endpoint — url, enabled, fail_mode open | closed (see below)
Server Webhooks

Available events

All events follow the format submission.<status>:

EventWhen it fires
submission.submittedApplicant clicks Submit
submission.in_reviewReviewer claims the submission
submission.more_info_requestedReviewer asks the applicant for clarification
submission.approvedSubmission moved to approved (if used directly)
submission.pending_publishReviewer approves the submission (standard approval path)
submission.publishedReviewer marks the nonprofit as live on your giving page
submission.rejectedReviewer rejects the submission
submission.withdrawnApplicant withdraws their application
test.pingSent only when you click "Send test ping"
organization.verification_changedPartner network API (opt-in): an organization's KYB, bank-account verification or IRS status changed
organization.bank_account_changedPartner 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_changedPartner network API (opt-in): a ban, IRS revocation or suspension, confirmed sanctions match, or high drift appeared or cleared
destination_token.revokedPartner 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.

Webhooks do not fire for demo submissions or for status transitions where no field actually changed.
Server Webhooks

Payload format

Every webhook is a JSON POST to your endpoint with these headers:

HeaderExample
Content-Typeapplication/json
User-AgentDAFConnect-Webhook/1.0
X-DAFConnect-Eventsubmission.pending_publish
X-DAFConnect-Delivery-Idevt_a3f1...
X-DAFConnect-Signaturet=1700000000,v1=abc123def456...
Body for submission.* events
{
  "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.

Respond fast
Return a 2xx response within 10 seconds. Slow handlers will time out and be marked failed in your delivery log. Do heavy work asynchronously. Redirects are not followed — a 3xx answer is logged as a failed delivery with that status, and any non-2xx as 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.
Server Webhooks

Verifying signatures

Every request includes an X-DAFConnect-Signature header of the form t=<timestamp>,v1=<hex_signature>. To verify:

  1. Extract t (timestamp) and v1 (signature)
  2. Concatenate `${t}.${rawBody}` (timestamp, dot, raw request body)
  3. Compute HMAC-SHA256 of that string using your webhook_secret
  4. Compare against v1 in constant time
  5. Reject events older than 5 minutes to prevent replay attacks
Node.js verification
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')
  );
}
Server Webhooks

Example handler

Express.js — complete 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);
Python (Flask)
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', 200
Server Webhooks

Testing & 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.

Server Webhooks

Per-submission authorization

Optional. When enabled, every finalized submission is held until your backend approves it — the way a card issuer authorizes each payment. DAF Connect POSTs a one-time challenge to your authorization endpoint; your server runs whatever logic it likes (CRM allowlist, budget check, fraud model) and answers with a cryptographic pin. The applicant never sees this step.

Configure it under Per-submission authorization on /app/webhooks (op register_authorization). The endpoint must be a public https:// URL — private and loopback hosts are refused — and it is signed with the same webhook_secret you already hold.

Request — POST to your endpoint
{
  "event": "submission.authorize",
  "challenge": "chal_9f2c...",          // fresh 24-byte nonce on every call
  "timestamp": 1700000000,
  "enterprise_id": "ent_abc123",
  "submission": {
    "id": "sub_xyz789",
    "nonprofit_name": "Acme Nonprofit Inc",
    "nonprofit_id": "np_abc"
  },
  "respond_with": "hmac_sha256_hex(webhook_secret, challenge + '.' + submission.id) as 'authorization'"
}

The request carries X-DAFConnect-Event: submission.authorize and the same X-DAFConnect-Signature header as a webhook delivery — verify it before acting. Respond within 8 seconds.

Reply — approve
{
  "approve": true,
  "authorization": "hex HMAC-SHA256 of challenge + '.' + submission.id, keyed with your webhook_secret"
}
Reply — decline
{
  "approve": false,
  "reason": "Not on our approved-grantee list"   // relayed to the applicant, up to 300 characters
}
OutcomeWhenEffect
approvedapprove: true with a correct pinThe submission finalizes normally
declinedapprove: falseThe submission is refused and your reason is relayed to the applicant
unavailableTimeout, a non-2xx status, a redirect, malformed JSON, or approve: true with a wrong pinDecided by your fail mode — open (default) accepts the submission, closed refuses it
An approve without the pin is not an approval
A reply of approve: true whose authorization does not match is treated as unavailable, never as approved — a spoofed or buggy endpoint cannot wave submissions through. The pin binds the challenge and the submission id, so it cannot be replayed, and only a holder of your signing secret can compute it.
Server Webhooks

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.

ActionWhat it returns
search_organizationOrganizations by name or EIN, with network_status (verified / unverified / ineligible / unknown)
get_organizationPublic registry data for one organization (by org_ref or EIN)
get_verification_statusOrganization and bank-account verification, IRS, sanctions / drift summaries, restrictions, and your own submission for it
get_grant_eligibilityA read-only dry run of the eligibility rules: decision + reason codes. Writes nothing
get_delivery_capabilitiesWhether a destination token can be minted, and the masked bank hint (last 4 only)
mint_destination_tokenA dct_live_… token scoped to one organization, grant reference and amount cap; requires X-DAFConnect-Idempotency-Key
revoke_destination_tokenRevokes one of your tokens (idempotent)
pingCredential and clock check
Every request is signed
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":{}}
The API secret is not the webhook secret
Issue the 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.
Examples

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>
  );
}
Examples

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)
});
Examples

Modal wrapper

See Modal pattern in the Embedding section above.

Examples

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;
  }
});
Examples

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
      }
    }, '*');
  }
});
Tools

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.

dafconnect-embed-test.html
~5 KB · Loads its one script from daf-connect.com · Runs locally in your browser
↓ Download

What you get

  • An iframe pointing at your /embed/:public_key URL (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

  1. Click Download above to save the file
  2. 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.
  3. Edit the iframe src input at the top to point at your enterprise's embed URL — e.g. https://daf-connect.com/embed/YOUR_PUBLIC_KEY — then click Reload
  4. Watch the dots:
    • iframe load green = the iframe responded
    • auto-resize green = received a dafconnect:resized message with a valid height
  5. Scroll through the onboarding flow — every step change, toast, and resize event will appear in the log
Need to test on your real domain?
The tool runs from 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.
Troubleshooting
  • "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.
Security & Compliance

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.

Encryption is mandatory
You cannot opt out. Even if Stripe is disabled and the applicant enters bank info manually, it's encrypted before storage.
Security & Compliance

Data retention

The enforceRetentionPolicies job runs daily and enforces these windows:

DataRetentionThen
Rejected / withdrawn submissions3 years from last activity (default)Soft-archived, then hard-deleted (with all child records) 30 days later
Approved / published submissionsNever auto-deleted—
Audit logs5 yearsTrimmed
Email logsTied to submission (5-year hard cap)Cascaded on submission delete
Stripe webhook events5 yearsTrimmed

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.

Security & Compliance

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.

Security & Compliance

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.

Security & Compliance

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.

Security & Compliance

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.

Security & Compliance

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.

Security & Compliance

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.

Reference

Entity reference

Core data model entities you'll encounter:

EntityPurpose
EnterpriseYour organization config — branding, domains, features
NonProfitOrganizationThe applicant — IRS data, contact info, trust score
SubmissionA single application from a nonprofit to an enterprise
SubmissionDocumentFiles uploaded during onboarding
VerificationCheckResult of one automated check (e.g. OFAC)
StripeVerificationResult of a Stripe verification call
AIVerificationResultLLM-powered approve/review/reject recommendation
DriftRecordA detected change since last verification
TrustEventLog entry for trust score changes
EmailLogRecord of every email sent to an applicant
InternalNotePrivate reviewer notes (submission or org-scoped)
AuditLogEvery data-changing action — HMAC-sealed, hash-linked, written only by the server
SubmissionStatusHistoryEvery status transition on a submission (the applicant-visible timeline)
IntakeLinkOne-time intake link — active until consumed, expired after 30 days unused
WebhookDeliveryOne webhook delivery attempt: event, HTTP status, response snippet, duration
KlawVerificationPeople-screening result from the reviewer-side verification pipeline
EnterpriseSecretService-role-only vault for the tenant encryption key and signing secrets — no member can read it
EnterpriseMembershipMaps 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.
Reference

Troubleshooting

ProblemSolution
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 failsEnsure iframe has allow="camera; microphone" and page is HTTPS
Resize event never firesResizeObserver requires modern browser — check polyfill
Submission stuck in "draft"Applicant didn't complete step 11 (Submit). Reviewer can force-submit
"unauthorized_origin" errorNo 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 runningCheck /app/audit — pipeline runs async after status=submitted
Decryption errors in reviewer dashboardReviewer not a member of submission's enterprise
Reference

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).

Contact support →

Two minutes to a live embed.