> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Security & Credential Management

> How to handle Raise API tokens, webhook secrets, and other credentials in partner integrations — secure storage, rotation, customer offboarding, and the platform-specific considerations for handling donation data.

A Raise integration handles two distinct credentials per customer: the Raise API token (used to authenticate API requests) and the webhook security token (used to verify incoming webhook deliveries). Both must be stored securely, rotated periodically, and removed when the customer offboards. The patterns on this page cover the production practices that keep these credentials safe across the integration's lifetime.

Beyond credentials, the page covers the broader security considerations for partner integrations: protecting donor data in transit and at rest, logging and redaction, compliance (especially the implications of touching donation flows), and the practices that prevent the integration from becoming a vector for attacks against the customer's account.

The audience is integration security leads and engineers building production Raise integrations.

## The two credentials

| Credential                 | Issued by                                               | Used for                                       | Lives in                                                            |
| -------------------------- | ------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------- |
| **Raise API token**        | Customer's Raise administrator                          | Bearer auth on all API requests                | Partner secrets manager                                             |
| **Webhook security token** | Partner integration (set on subscription create/update) | Signing outgoing webhook deliveries from Raise | Both partner secrets manager and Raise's stored subscription record |

Each customer has their own tokens — never share tokens across customers, even within the same partner. The blast radius of a compromise should be one customer, not all of them.

***

## API token storage

The customer's Raise administrator generates an API token in the Raise admin UI and provides it to the partner integration through whatever onboarding flow the partner has built. From the moment the partner receives it, the token must be treated as a sensitive secret.

### Storage practices

| Practice                        | Description                                                                                                                                                          |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Store in a secrets manager**  | AWS Secrets Manager, HashiCorp Vault, Google Secret Manager, Azure Key Vault. Never in a code repository, environment variable file, or unencrypted database column. |
| **Encrypt at rest**             | The secrets manager handles this; for database-backed storage, encrypt the column with a separate key.                                                               |
| **Restrict access by IAM/RBAC** | Only the production sync workers should be able to read tokens. Developers should not have direct production access.                                                 |
| **Audit access**                | Every read of a customer's token should be logged. Anomalies (a token read by an unfamiliar service or at an unusual time) should alert.                             |
| **Never log the token**         | Logging frameworks often serialize objects fully — make sure tokens are explicitly redacted before logging.                                                          |

### A storage abstraction

```javascript JavaScript theme={null}
class CredentialStore {
  constructor(secretsManager) {
    this.secretsManager = secretsManager;
  }

  async getRaiseToken(customerId) {
    const secret = await this.secretsManager.getSecret(
      `raise-integration/${customerId}/api-token`
    );
    return secret.value;
  }

  async setRaiseToken(customerId, token) {
    await this.secretsManager.putSecret(
      `raise-integration/${customerId}/api-token`,
      { value: token, updatedAt: new Date().toISOString() }
    );
  }

  async getWebhookSecret(customerId) {
    const secret = await this.secretsManager.getSecret(
      `raise-integration/${customerId}/webhook-secret`
    );
    return secret.value;
  }

  async setWebhookSecret(customerId, secret) {
    await this.secretsManager.putSecret(
      `raise-integration/${customerId}/webhook-secret`,
      { value: secret, updatedAt: new Date().toISOString() }
    );
  }
}

// Usage — never reach into the secrets manager directly from worker code
const credentials = new CredentialStore(secretsManager);
const token = await credentials.getRaiseToken(customerId);
```

The abstraction makes it easy to add caching, audit logging, and other cross-cutting concerns in one place.

### Token caching

For high-volume integrations, the secrets manager's read latency can dominate request time. A short-TTL in-memory cache mitigates this:

```javascript JavaScript theme={null}
class CachedCredentialStore {
  constructor(underlying, ttlSeconds = 300) {
    this.underlying = underlying;
    this.ttlMs = ttlSeconds * 1000;
    this.cache = new Map();
  }

  async getRaiseToken(customerId) {
    const cached = this.cache.get(`token:${customerId}`);
    if (cached && Date.now() - cached.cachedAt < this.ttlMs) {
      return cached.value;
    }
    const token = await this.underlying.getRaiseToken(customerId);
    this.cache.set(`token:${customerId}`, { value: token, cachedAt: Date.now() });
    return token;
  }
}
```

5-minute TTL is a reasonable default. Shorter TTLs reduce blast radius during rotation; longer TTLs reduce secrets-manager cost. Tune based on your specific platform.

***

## API token rotation

Tokens should be rotated periodically — typically every 90 days for high-stakes integrations, every 6–12 months for lower-stakes ones. The rotation pattern is straightforward but requires coordination with the customer.

### The rotation flow

<Steps>
  <Step title="Customer's Raise administrator generates a new token">
    In the Raise admin UI. The old token typically remains valid during the rotation window.
  </Step>

  <Step title="Customer provides the new token to the partner">
    Through your onboarding/settings flow.
  </Step>

  <Step title="Partner stores the new token alongside the old one">
    Both are marked valid in your credential store.
  </Step>

  <Step title="Partner switches to using the new token">
    Workers re-read credentials and start using the new token.
  </Step>

  <Step title="Confirm requests succeed with the new token">
    Monitor for 401 errors that would indicate a problem.
  </Step>

  <Step title="Customer revokes the old token in Raise">
    Once the new token is verified working.
  </Step>

  <Step title="Partner removes the old token from secrets manager">
    Completing the rotation.
  </Step>
</Steps>

### A rotation helper

```javascript JavaScript theme={null}
async function rotateRaiseToken(customerId, newToken) {
  // Validate the new token works before committing
  const testResponse = await fetch(
    'https://prod-api.raisedonors.com/api/Donor/list?Take=1',
    { headers: { Authorization: `Bearer ${newToken}` } }
  );

  if (!testResponse.ok) {
    throw new Error(`New token failed validation: ${testResponse.status}`);
  }

  // Mark the old token as previous (kept for rollback)
  const oldToken = await credentials.getRaiseToken(customerId);
  await credentials.setRaiseToken(customerId, newToken);
  await credentials.setPreviousRaiseToken(customerId, oldToken);

  // Invalidate any cached credentials
  await credentials.invalidateCache(customerId);

  // Schedule deletion of the old token after a grace period
  await scheduleTokenCleanup(customerId, 'previous', '7 days');

  await audit.log({
    customerId,
    action: 'token_rotated',
    timestamp: new Date(),
  });
}
```

The validation step catches "the customer pasted the wrong thing" before the new token gets stored. The previous-token retention provides a rollback window in case something goes wrong.

### Triggering rotation reminders

For partner integrations with many customers, rotation should be tracked centrally:

```javascript JavaScript theme={null}
async function findCustomersNeedingRotation(maxAgeDays = 90) {
  const all = await credentials.listAll();
  const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000);

  return all.filter((c) => c.tokenUpdatedAt < cutoff);
}

// Run weekly
async function rotationReminderJob() {
  const needRotation = await findCustomersNeedingRotation(80); // 10-day warning
  for (const customer of needRotation) {
    await emailService.send({
      to: customer.adminEmail,
      template: 'token-rotation-reminder',
      data: { lastRotated: customer.tokenUpdatedAt },
    });
  }
}
```

Reminders to the customer 10 days before the rotation deadline give them time to act without urgency. Customers who don't rotate by the deadline get more urgent reminders; customers who don't rotate within a longer window get escalated.

***

## Webhook secret management

The webhook security token has different lifecycle than the API token — the partner integration generates it (rather than receiving it from the customer), stores it on both sides (partner secrets manager and Raise's subscription record), and rotates it differently.

### Generating a strong secret

```javascript JavaScript theme={null}
import crypto from 'crypto';

function generateWebhookSecret() {
  // 32 bytes of cryptographically random data = 256 bits of entropy
  return crypto.randomBytes(32).toString('base64');
}
```

256 bits of entropy is essentially impossible to brute-force. Don't reduce below 128 bits.

### Storing both sides

```javascript JavaScript theme={null}
async function createWebhookSubscription(customerId) {
  const settings = customerSettings[customerId];
  const secret = generateWebhookSecret();

  // 1. Store on the partner side first
  await credentials.setWebhookSecret(customerId, secret);

  // 2. Create the subscription in Raise
  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}`,
      notificationUrl: `https://partner.example.com/raise-webhooks/${customerId}`,
      eventTypesList: [10, 11, 20, 22],
      format: 1,
      status: 1,
      securityToken: secret,
    }),
  });

  const webhook = await response.json();

  // 3. Store the webhook ID for management
  await persistCustomerSettings(customerId, {
    raiseWebhookId: webhook.id,
  });

  return webhook;
}
```

The order matters — store the secret on the partner side **before** creating the subscription. If the subscription create succeeds but the secret storage fails, you have a working webhook delivery you can't verify. If the secret storage succeeds but the subscription create fails, you have an orphaned secret that's easy to clean up.

### Webhook secret rotation

The dual-secret pattern (verify against either current or previous secret during the rotation window):

<Steps>
  <Step title="Generate a new secret">
    Cryptographically random, 32 bytes.
  </Step>

  <Step title="Update the partner-side storage with both secrets">
    Both are marked valid for verification.
  </Step>

  <Step title="Update the webhook subscription with the new secret via PUT">
    Raise begins signing with the new secret immediately.
  </Step>

  <Step title="Confirm new deliveries verify with the new secret">
    Inspect webhook logs and partner-side verification metrics.
  </Step>

  <Step title="Remove the old secret from partner-side storage">
    After confirmation, typically a few hours later.
  </Step>
</Steps>

The dual-secret window is brief but essential — without it, in-flight deliveries signed with the old secret would fail during the cutover.

```javascript JavaScript theme={null}
async function rotateWebhookSecret(customerId) {
  const settings = customerSettings[customerId];
  const newSecret = generateWebhookSecret();
  const oldSecret = await credentials.getWebhookSecret(customerId);

  // 1. Store both secrets (dual-verify window starts)
  await credentials.setWebhookSecret(customerId, newSecret);
  await credentials.setPreviousWebhookSecret(customerId, oldSecret);

  // 2. Update the subscription in Raise
  await fetch(
    `https://prod-api.raisedonors.com/api/Webhook/${settings.raiseWebhookId}`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${settings.raiseApiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        // ... existing subscription fields ...
        securityToken: newSecret,
      }),
    }
  );

  // 3. Schedule cleanup of the old secret
  await scheduleSecretCleanup(customerId, 'previous-webhook', '1 hour');
}
```

And the dual-verify function during the rotation window:

```javascript JavaScript theme={null}
async function verifyRaiseWebhook(req, customerId) {
  const primary = await credentials.getWebhookSecret(customerId);
  if (verifyWithSecret(req, primary)) return true;

  const previous = await credentials.getPreviousWebhookSecret(customerId);
  if (previous && verifyWithSecret(req, previous)) return true;

  return false;
}
```

See [Signature Verification: Rotating the secret](/raise/webhooks/signature-verification#rotating-the-secret) for the broader rotation pattern.

***

## Customer offboarding

When a customer cancels the integration, both credentials should be removed cleanly:

<Steps>
  <Step title="Delete the webhook subscription in Raise">
    `DELETE /api/Webhook/{id}` to stop event deliveries.
  </Step>

  <Step title="Remove the API token from partner secrets manager">
    No reason to retain it.
  </Step>

  <Step title="Remove the webhook secret from partner secrets manager">
    Same.
  </Step>

  <Step title="Mark the customer as offboarded in your system">
    Subsequent sync attempts should fail loudly rather than silently — the lack of credentials is intentional.
  </Step>

  <Step title="Document the offboarding for audit">
    When the offboarding happened, who triggered it, why.
  </Step>
</Steps>

```javascript JavaScript theme={null}
async function offboardCustomer(customerId, reason) {
  const settings = customerSettings[customerId];

  // 1. Delete the webhook subscription
  if (settings.raiseWebhookId) {
    try {
      await fetch(
        `https://prod-api.raisedonors.com/api/Webhook/${settings.raiseWebhookId}`,
        {
          method: 'DELETE',
          headers: { Authorization: `Bearer ${settings.raiseApiToken}` },
        }
      );
    } catch (err) {
      console.warn(`Failed to delete webhook for ${customerId}:`, err);
      // Continue with offboarding even if this fails — we'll clean up our side
    }
  }

  // 2. Remove credentials
  await credentials.deleteAllForCustomer(customerId);

  // 3. Mark customer offboarded
  await customerStore.markOffboarded(customerId, {
    offboardedAt: new Date(),
    reason,
  });

  // 4. Audit
  await audit.log({
    customerId,
    action: 'offboarded',
    reason,
    timestamp: new Date(),
  });
}
```

Don't retain credentials "just in case the customer comes back." If they do, they'll generate new tokens. Retaining old credentials adds compliance risk for no operational benefit.

***

## Donor data handling

Beyond credentials, Raise integrations handle donor data (PII) that requires its own protections.

### Encryption in transit

All API calls to Raise use HTTPS. All webhook deliveries from Raise should arrive over HTTPS to the partner's endpoint. Verify both directions:

```javascript JavaScript theme={null}
// Confirm partner-side endpoint enforces HTTPS
app.use((req, res, next) => {
  if (!req.secure && process.env.NODE_ENV === 'production') {
    return res.status(403).send('HTTPS required');
  }
  next();
});
```

The webhook subscription's `notificationUrl` must be `https://`. Don't try to use plain HTTP even for development — set up local HTTPS tunneling instead. See [Local Testing](/raise/webhooks/local-testing).

### Encryption at rest

For partner-side databases storing donor data, encrypt at rest:

| Field        | Sensitivity | Encryption approach                                                       |
| ------------ | ----------- | ------------------------------------------------------------------------- |
| Email        | PII         | Application-level encryption recommended; required for some jurisdictions |
| Name         | PII         | Application-level encryption recommended                                  |
| Phone        | PII         | Application-level encryption recommended                                  |
| Address      | PII         | Application-level encryption recommended                                  |
| Gift amount  | Financial   | Standard database encryption usually sufficient                           |
| Card details | PCI         | **Never stored on the partner side** — Raise handles tokenization         |

Don't store raw card details under any circumstances. Card tokenization happens at Raise's hosted donation form; the partner only sees `paymentMethodId` (a non-sensitive token) and metadata like `cardBrand` and `expMonthAndYear`.

### Logging and redaction

Logs are a common source of accidental credential exposure. Build redaction into the logging framework:

```javascript JavaScript theme={null}
function safeLog(message, data) {
  const redacted = redactSensitive(data);
  logger.info(message, redacted);
}

function redactSensitive(obj) {
  const SENSITIVE_KEYS = [
    'token', 'apiToken', 'authorization', 'auth',
    'securityToken', 'webhookSecret', 'secret',
    'password', 'apiKey', 'privateKey',
    'cardNumber', 'cvv', 'cvc',
  ];

  if (typeof obj !== 'object' || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(redactSensitive);

  return Object.fromEntries(
    Object.entries(obj).map(([key, value]) => {
      if (SENSITIVE_KEYS.some((s) => key.toLowerCase().includes(s.toLowerCase()))) {
        return [key, '[REDACTED]'];
      }
      return [key, redactSensitive(value)];
    })
  );
}
```

Apply redaction at the logging-framework level so it can't be forgotten in individual call sites. Audit logs periodically for missed cases.

***

## Compliance considerations

Partner integrations handling donation data inherit compliance obligations from their customers.

### PCI DSS

| If your integration                                              | PCI scope                                   |
| ---------------------------------------------------------------- | ------------------------------------------- |
| Embeds the Raise hosted form and lets Raise tokenize             | Out of scope — never sees card data         |
| Submits to `POST /api/Raise/give` with a `paymentMethodId` token | Out of scope — tokens aren't card data      |
| Implements its own card capture and sends raw card data          | In scope — full PCI DSS compliance required |

For most partner integrations, **stay out of PCI scope by letting Raise tokenize.** The compliance overhead of being in PCI scope is substantial; the value of capturing card details yourself is rarely worth it.

### GDPR / data privacy

For customers operating in jurisdictions with strict data privacy laws (EU GDPR, California CCPA, etc.):

| Practice                                            | Required by  |
| --------------------------------------------------- | ------------ |
| Data minimization — store only what's needed        | GDPR Art. 5  |
| Right to erasure — delete on customer/donor request | GDPR Art. 17 |
| Data processing agreement with the customer         | GDPR Art. 28 |
| Breach notification within 72 hours                 | GDPR Art. 33 |
| Consent tracking for marketing                      | GDPR Art. 7  |

Partner integrations should:

* Document what donor data they retain and for how long.
* Provide a deletion mechanism for individual donor data.
* Sign data processing agreements with customers in regulated jurisdictions.
* Implement appropriate technical and organizational measures (TOMs).

### SOC 2 / similar certifications

For partner integrations serving enterprise nonprofit customers, SOC 2 Type II certification is increasingly expected. The certification documents controls in areas like:

* Access controls and authentication
* Encryption in transit and at rest
* Change management
* Incident response
* Vendor management

The credential management practices on this page directly support several SOC 2 control areas.

***

## Attack surface considerations

Beyond credential hygiene, partner integrations should be designed to limit attack surface:

### Endpoint hardening

| Practice                                         | Description                                                  |
| ------------------------------------------------ | ------------------------------------------------------------ |
| **Rate limit incoming webhooks**                 | Prevent abuse where an attacker hits the endpoint repeatedly |
| **Validate signatures before processing**        | An unsigned request is a forged request                      |
| **Limit request body size**                      | Don't accept arbitrarily large bodies                        |
| **Disable verbose error messages in production** | Stack traces leak implementation details                     |
| **Use a Web Application Firewall**               | An extra defense against common attack patterns              |

### Principle of least privilege

Each component of the integration should have the minimum credentials it needs:

| Component              | Credentials                                                         |
| ---------------------- | ------------------------------------------------------------------- |
| Sync worker            | Read-only access to credentials, read/write to sync state           |
| Onboarding service     | Write access to credentials (new tokens), no production data access |
| Customer support tools | Read-only access to sync state, no credential access                |
| Analytics / reporting  | Read-only access to sync state, no credential or PII access         |

A compromise of any single component should not provide access to credentials or PII it doesn't need.

### Audit logging

Every credential access, every customer offboarding, every token rotation should produce an audit log entry:

```javascript JavaScript theme={null}
await audit.log({
  customerId,
  actor: 'sync-worker-prod-3',
  action: 'token_read',
  resource: 'raise_api_token',
  timestamp: new Date(),
  context: { workerInstance: process.env.HOSTNAME },
});
```

Audit logs should be tamper-resistant — typically stored in a separate system from the application's main database, with access tightly restricted. Retain for at least a year, longer in regulated jurisdictions.

***

## A security checklist

Walk through this when designing or auditing the integration:

* Raise API tokens stored in a secrets manager, never in code or environment files
* Webhook secrets generated with 32 bytes of cryptographic random
* Per-customer credentials — no shared tokens across customers
* HTTPS everywhere — API calls and webhook deliveries
* Signature verification on every incoming webhook before any processing
* Logging framework includes credential redaction by default
* No raw card data ever stored on the partner side
* Customer offboarding deletes credentials and the webhook subscription
* Token rotation tracked centrally with reminders
* Webhook secret rotation uses the dual-verify pattern
* Sensitive donor data (email, name, address) encrypted at rest
* Audit logs for all credential access and customer lifecycle events
* Production access controlled by IAM/RBAC; developers don't have direct production access
* PCI scope is `out` — Raise handles all card tokenization
* Data processing agreements signed with customers in regulated jurisdictions
* Incident response runbook exists for credential compromise

Most of these are policy decisions as much as technical ones. The investment in getting them right early is much smaller than the cost of fixing them after a breach.

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Authentication" icon="key" href="/raise/authentication">
    The reference for the Raise API token authentication pattern.
  </Card>

  <Card title="Signature Verification" icon="shield-check" href="/raise/webhooks/signature-verification">
    The webhook signature verification pattern that uses the secrets discussed here.
  </Card>

  <Card title="Versioning and Backward Compatibility" icon="code-branch" href="/raise/best-practices/versioning-and-backward-compatibility">
    The durability practices that complement security practices.
  </Card>

  <Card title="Error Recovery Patterns" icon="arrows-rotate" href="/raise/best-practices/error-recovery-patterns">
    The 401-handling and credential-refresh patterns referenced in this page.
  </Card>
</CardGroup>
