Skip to main content
This recipe walks through the complete project of adding a Raise donation form to a customer’s website — from the admin-team setup work through the partner integration’s wiring and into production monitoring. It’s the most common partner integration scenario for Raise, and the patterns here apply with small variations to many partner-built fundraising experiences. The audience is a partner integration team building or maintaining a website-integration product for a nonprofit customer. The customer has (or is creating) a Raise account; the partner is responsible for the technical wiring.

The scenario

Customer: A mid-size nonprofit with a marketing website and a Raise account. They want a donation form on their /donate page that captures gifts directly in Raise. Partner: A web platform or agency that hosts (or develops) the customer’s website. The customer has asked the partner to wire up the donation form, react to completed gifts, and send custom thank-you emails. Constraints:
  • The donation form itself is configured in Raise (form fields, designations, payment processor) — not via API.
  • The partner needs to embed the form on the customer’s existing website without breaking the rest of the site.
  • After each donation, the partner needs to send a custom-branded thank-you email and notify the customer’s team in Slack.
  • The integration must work for the customer’s test donations during development and for real donations in production.

Architecture overview

Two flows:
  • Donation flow (left to right): donor visits the customer’s site, the embed loads from Raise, the donor completes the form, Raise processes the payment.
  • Post-donation flow (right side): Raise fires a webhook to the partner, the partner triggers downstream actions.
The partner integration sits entirely on the post-donation side. The donation form itself is Raise’s hosted product, embedded on the customer’s site.

Prerequisites

Before starting the project, confirm these are in place:
PrerequisiteHow to confirm
Customer’s Raise account is activeCustomer can log into the Raise admin UI
Customer’s payment gateway is configuredCustomer’s Raise account can accept test donations
Customer has created the Donation Form in the admin UICustomer can share the form’s URL and embed code
Customer has issued a Raise API token to the partnerToken is in the partner’s secrets manager
Partner has a deployment target for the webhook receiverProduction HTTPS URL ready (e.g., https://partner.example.com/raise-webhooks)
Email service and Slack integration are wiredPartner can send emails and post to Slack programmatically
If any of these aren’t in place, address them before writing integration code.

Step 1: get the embed code from the customer

The customer’s Raise administrator opens the form in the admin UI and copies the embed code. They provide it to the partner through whatever onboarding flow the partner has built — typically a settings field in the partner’s product.
Customer settings (partner UI)
─────────────────────────────────────────────
Raise donation form embed code:
[ <iframe src="..." ... ></iframe>            ]
─────────────────────────────────────────────
Validate the code: must be non-empty and contain an iframe or script tag pointing at a Raise domain.
Store the embed code as a per-customer setting:
JavaScript
// settings/customer-config.ts (example)
const customerSettings = {
  customer_wayne_foundation: {
    displayName: 'Wayne Foundation',
    raiseApiToken: '...',                    // From secrets manager
    raiseEmbedCode: '<iframe src="..." ></iframe>',
    raiseFormId: 1234,                       // Captured from first received webhook
    raiseWebhookId: null,                    // Populated in step 3
    raiseWebhookSecret: null,                // Populated in step 3
    thankYouEmailTemplate: 'wayne-foundation-thank-you',
    slackChannel: '#wayne-foundation-donations',
    majorGiftThreshold: 1000,
  },
};
The customer’s raiseFormId may not be known yet — it will be visible in the first webhook event the partner receives. Leave it as null initially and capture it from the first delivery.

Step 2: build the donation page

The partner serves the customer’s /donate page with the embed code dropped in. A minimal Express handler:
JavaScript
import express from 'express';

const app = express();

app.get('/donate', async (req, res) => {
  const customerId = identifyCustomer(req); // E.g., from domain or session
  const settings = customerSettings[customerId];

  if (!settings?.raiseEmbedCode) {
    return res.status(404).send('Donation form not configured');
  }

  res.render('donation-page', {
    displayName: settings.displayName,
    embedCode: settings.raiseEmbedCode,
  });
});
The template:
donation-page.ejs
<!DOCTYPE html>
<html>
<head>
  <title>Support <%= displayName %></title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <header>
    <h1>Support <%= displayName %></h1>
    <p>Your gift makes a difference.</p>
  </header>

  <main>
    <!-- Begin Raise form embed -->
    <%- embedCode %>
    <!-- End Raise form embed -->
  </main>

  <footer>
    <p>Thank you for your support!</p>
  </footer>
</body>
</html>
Three template practices that matter:
  • Use raw output (<%- %> in EJS, {{{ }}} in Handlebars, etc.). The embed code is HTML; escaping it would prevent the embed from rendering.
  • Serve the page over HTTPS. Raise’s embed loads resources over HTTPS; embedding on HTTP produces mixed-content warnings or blocks the form.
  • Don’t wrap the embed in another iframe or apply transforms. The embed code is designed to work as Raise provides it.
See Embed a Form: Step 2: render the embed code for the broader pattern.

Step 3: subscribe to the webhook

The partner integration needs to know when donations complete. Subscribe to gift events for this customer:
JavaScript
import crypto from 'crypto';

async function setupWebhookForCustomer(customerId) {
  const settings = customerSettings[customerId];
  const webhookSecret = crypto.randomBytes(32).toString('base64');

  const response = await fetch(
    'https://prod-api.raisedonors.com/api/Webhook',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${settings.raiseApiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: `Partner integration — ${customerId} — gift events`,
        notificationUrl: `https://partner.example.com/raise-webhooks/${customerId}`,
        eventTypesList: [10, 11], // Gift Created and Gift Updated (per working hypothesis)
        format: 1,
        status: 1,
        securityToken: webhookSecret,
      }),
    }
  );

  const webhook = await response.json();

  // Persist webhook ID and secret in the customer settings
  await persistCustomerSettings(customerId, {
    raiseWebhookId: webhook.id,
    raiseWebhookSecret: webhookSecret,
  });

  return webhook;
}
Notes:
  • The notificationUrl includes customerId in the path so the receiver can identify which customer’s event is arriving.
  • The secret is generated fresh per customer — never reuse secrets across customers.
  • Event type 10 is the working-hypothesis value for “Gift Created”; confirm via discovery against the customer’s test events. See Event Types: Discovering the mapping.
Run this once during customer onboarding. Store the returned webhook.id and the secret in the customer’s settings.

Step 4: build the webhook receiver

The receiver handles incoming events. Three responsibilities: verify the signature, acknowledge quickly, queue for processing.
JavaScript
import express from 'express';
import crypto from 'crypto';

const app = express();

// Capture raw body for signature verification
app.use('/raise-webhooks/:customerId', express.raw({ type: 'application/json' }));

function verifySignature(req, secret) {
  const signature = req.headers['x-raise-signature']; // Confirm name against live data
  if (!signature) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  if (signature.length !== expected.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'utf8'),
    Buffer.from(expected, 'utf8')
  );
}

app.post('/raise-webhooks/:customerId', async (req, res) => {
  const { customerId } = req.params;
  const settings = customerSettings[customerId];

  if (!settings) {
    return res.status(404).send('Unknown customer');
  }

  if (!verifySignature(req, settings.raiseWebhookSecret)) {
    return res.status(401).send('Invalid signature');
  }

  // Acknowledge first
  res.status(200).send('OK');

  // Then queue for async processing
  const event = JSON.parse(req.body.toString('utf8'));
  await eventQueue.publish({
    customerId,
    eventType: event.eventType,
    payload: event.payload ?? event,
    receivedAt: new Date(),
  });
});
See Signature Verification for the full verification pattern. Confirm the header name (x-raise-signature above is a placeholder) by inspecting real deliveries.

Step 5: process events asynchronously

The worker drains the queue and runs the post-donation actions:
JavaScript
const handlers = {
  10: handleGiftCreated, // Working hypothesis
  11: handleGiftUpdated,
};

async function processQueuedEvent(queueItem) {
  const { customerId, eventType, payload } = queueItem;

  // Dedup check — see Idempotency and Safe Reprocessing
  const eventKey = `${customerId}:${eventType}:${payload.id}:${payload.modifiedDate}`;
  if (await dedupStore.hasProcessed(eventKey)) return;

  const handler = handlers[eventType];
  if (!handler) {
    console.warn(`No handler for event type ${eventType}`);
    return;
  }

  try {
    await handler(customerId, payload);
    await dedupStore.recordProcessed(eventKey);
  } catch (err) {
    console.error(`Processing failed for ${eventKey}:`, err);
    // Re-throw so the queue retries
    throw err;
  }
}

async function handleGiftCreated(customerId, gift) {
  const settings = customerSettings[customerId];

  // 1. Capture the form ID if we haven't seen it before
  if (!settings.raiseFormId && gift.form?.id) {
    await persistCustomerSettings(customerId, { raiseFormId: gift.form.id });
  }

  // 2. Filter out test-mode gifts in production
  if (gift.isTestMode && process.env.NODE_ENV === 'production') {
    console.log(`Skipping test-mode gift ${gift.id} in production`);
    return;
  }

  // 3. Send the thank-you email
  await sendThankYouEmail(customerId, gift);

  // 4. Post to Slack
  await postToSlack(customerId, gift);

  // 5. Alert on major gifts
  if (gift.amount >= settings.majorGiftThreshold) {
    await alertMajorDonorTeam(customerId, gift);
  }
}
The handler:
  • Captures the form ID on first delivery (populates the raiseFormId left null in step 1).
  • Filters test-mode gifts in production to avoid sending test emails to real donors.
  • Triggers the customer-branded thank-you email.
  • Posts to the customer’s Slack channel.
  • Alerts the customer’s major-donor team for large gifts.

Step 6: send the thank-you email

The thank-you email uses customer-specific templates and pulls fields from the gift record:
JavaScript
async function sendThankYouEmail(customerId, gift) {
  const settings = customerSettings[customerId];

  await emailService.send({
    template: settings.thankYouEmailTemplate,
    to: gift.donor?.email,
    data: {
      donorFirstName: gift.donor?.firstName,
      donorLastName: gift.donor?.lastName,
      amount: gift.formattedAmount,
      date: gift.date,
      project: gift.projects?.[0]?.projectName,
      campaign: gift.campaignName,
      organizationName: settings.displayName,
    },
  });
}
Notes:
  • Use gift.donor.email — the donor’s primary email captured at donation time.
  • Use gift.formattedAmount for display — it’s already in the donor’s currency with the right number of decimals.
  • Pass project and campaign to the template so the email can mention what the gift supported.
The template itself (managed by your email service — SendGrid, Postmark, Mailgun, etc.) uses these variables to produce the final email.

Step 7: post to Slack

A simple notification with the gift details:
JavaScript
async function postToSlack(customerId, gift) {
  const settings = customerSettings[customerId];

  await slackApi.postMessage({
    channel: settings.slackChannel,
    text: `New donation: ${gift.formattedAmount} from ${gift.donor?.name ?? 'Anonymous'}`,
    blocks: [
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `*New donation: ${gift.formattedAmount}*` },
      },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Donor:*\n${gift.donor?.name ?? 'Anonymous'}` },
          { type: 'mrkdwn', text: `*Project:*\n${gift.projects?.[0]?.projectName ?? 'General'}` },
          { type: 'mrkdwn', text: `*Campaign:*\n${gift.campaignName ?? 'Unknown'}` },
          { type: 'mrkdwn', text: `*Form:*\n${gift.form?.title ?? 'Unknown'}` },
        ],
      },
    ],
    idempotency_key: `raise-gift-${gift.id}`,
  });
}
The idempotency_key field (supported by Slack and many other systems) provides an extra defense against duplicate notifications even if the dedup store check is bypassed.

Step 8: alert the major-donor team

For gifts over the customer’s configured threshold, a higher-touch alert:
JavaScript
async function alertMajorDonorTeam(customerId, gift) {
  const settings = customerSettings[customerId];

  // Major donor channel (typically more private than the general donations channel)
  await slackApi.postMessage({
    channel: '#major-donor-alerts',
    text: `🌟 Major gift: ${gift.formattedAmount} from ${gift.donor?.name}`,
    blocks: [
      // ... formatted detail blocks ...
    ],
  });

  // Email the team lead
  await emailService.send({
    to: settings.majorDonorTeamEmail,
    subject: `Major gift alert: ${gift.formattedAmount}`,
    template: 'major-donor-alert',
    data: {
      gift,
      donorRecentGifts: await fetchDonorRecentGifts(customerId, gift.donorId),
    },
  });
}
The major-donor email might include the donor’s recent giving history — useful context for the cultivation team. Use GET /api/Donor/{donorId}/gifts to fetch the donor’s recent gifts.

Step 9: validate end-to-end in test mode

Before going live, run a complete end-to-end test:
1

Generate a test payment method

curl -X POST https://prod-api.raisedonors.com/api/Raise/generate-test-payment-method \
  -H "Authorization: Bearer $CUSTOMER_TOKEN"
Save the returned paymentMethodId for the next step.
2

Submit a test donation through the embedded form

Open the customer’s /donate page. Fill out the form with test details (name, email, amount). Use the test card details the customer’s gateway provides for test mode (typically 4242 4242 4242 4242 or similar; consult the gateway’s documentation).
3

Confirm the event arrives

Watch your webhook receiver’s logs. Within seconds of submission, a webhook event should arrive. If it doesn’t, check GET /api/Webhook/{id}/log/list to see whether Raise attempted delivery and what status code your receiver returned.
4

Confirm signature verification passes

Check that the event made it past the signature verification gate. If not, see Signature Verification for the troubleshooting flow.
5

Confirm the thank-you email sent

Check the inbox of the email address you submitted. The thank-you email should arrive within a minute of the donation.
6

Confirm Slack notification posted

Check the customer’s Slack channel for the donation notification.
7

Confirm dedup works

Use the webhook log endpoint to trigger a redelivery (some platforms support this; if Raise doesn’t, manually replay the captured payload through your queue). Confirm the second processing produces no second email and no second Slack post.
Each step confirms one piece of the integration. Running them in order makes it easy to identify which piece is failing if the end-to-end flow doesn’t work the first time.

Step 10: go live

Once test mode validates cleanly, switch to production:
SettingTest valueProduction value
Webhook subscription status1 (active during test)1 (active)
isTestMode filter in handlerProcess test eventsSkip test events
Email recipientTest email accountReal donor email
Slack channel#dev-notificationsCustomer’s real channel
Major-donor team emailTest email accountReal team lead
The cutover is typically just a configuration change — flip the settings to production values and let real donations flow through. Monitor closely for the first few real donations to confirm the production setup works as it did in test.

Production monitoring

Long-term, keep an eye on:
MetricWhat it tells you
Webhook delivery success rateHealth of the partner endpoint and the Raise → partner pipeline
Time from gift to thank-you emailEnd-to-end latency; should be seconds to a minute
Email delivery rateWhether thank-you emails actually arrive (handle bounces)
Dedup decision rateFrequency of duplicate deliveries — small spikes are normal, large spikes indicate issues
Failed signature verificationsShould be zero in steady state; non-zero indicates configuration drift or attacker probing
Build dashboards for these and alert on anomalies. The investment pays off the first time a customer asks “did the donations from Tuesday’s appeal make it through?” and you can answer with confidence.

Variations

A few common variations on this recipe:
VariationAdjustments
Multiple forms per customerAdd per-form configuration (raiseFormIds: [...]) and route processing based on gift.form.id
Multiple customers per Slack channelMost settings stay per-customer; Slack just receives different displayName in messages
No SlackSkip steps 7 and 8; keep email-only thank-you flow
External CRM sync alongside thank-youAdd a CRM sync action in the handler — see Sync Raise Gifts to an External System
Per-form thank-you templatesUse gift.form.id to select a template instead of one customer-wide template
The core architecture (embed + webhook + queue + worker) supports many variations without significant restructuring.

Where to go next

Customize the Donation Flow

Pre-populate donor data, override defaults, and customize the donation experience.

Post-Donation Thank-You Flows

Deeper coverage of the email and notification patterns for steps 6–8.

Embed a Form (Workflow)

The general-purpose workflow that underlies this recipe.

Sync Raise Gifts to an External System

Extend this recipe to also sync gifts into the partner’s accounting, BI, or CRM system.
Last modified on May 19, 2026