Documentation

Credential management

How AppEngine stores, rotates, and revokes vendor credentials.

Vendor credentials — OAuth tokens, API keys, webhook secrets — are sensitive data and AppEngine treats them accordingly. Every credential is encrypted at rest, scoped to one org, and accessed only via the Upstream service. Application code never reads raw credential values.

Storage shape

Credentials live on integration records in the integration collection. The credential blob is encrypted with a per-org key derived from the platform master key plus the org ID. The encrypted blob is what the database sees; the cleartext exists only in process memory while a vendor call is being made.

A typical integration record:

{
  "pk": "my-org|integration",
  "sk": "my-org|integration|stripe-prod",
  "data": {
    "type": "stripe-provider",
    "name": "Stripe Production",
    "status": "active",
    "credentials": "<encrypted blob>",
    "publicConfig": { "currency": "USD", "countryCode": "US" },
    "lastTestedAt": "2026-04-25T10:00:00Z",
    "lastUsedAt": "2026-04-25T11:42:13Z"
  }
}

publicConfig is unencrypted and safe to read — non-secret config that the admin UI shows. credentials is the encrypted blob containing keys, tokens, and secrets.

Reading credentials safely

GET/upstream/get-config/:type?/:configId?JWT
POST/upstream/get-integrationJWT

These endpoints return publicConfig plus a redacted form of credentials — secret fields appear as *** so the admin UI can render "API Key: ***xxxx (last rotated April 1)" without ever exposing the full key.

Server-side, the Upstream service has a privileged path that decrypts in memory just before the vendor SDK call. Application controllers don't have access to this path.

Setting credentials

POST/upstream/save-integrationJWT

Save accepts the cleartext credential, validates it (length, format, smoke test against the vendor where supported), encrypts, and stores. Partial updates work: send only the fields you're changing, and existing fields are preserved.

// Rotate just the webhook secret, leave the API key alone
await fetch('/api/upstream/save-integration', {
  method: 'POST',
  headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
  body: JSON.stringify({
    id: 'stripe-prod',
    credentials: { webhookSecret: 'whsec_new...' },
  }),
});

Permission: RoleType.ConfigAdmin is required. A ContentAdmin cannot view or modify credentials — only platform administrators can.

Rotation

OAuth refresh tokens rotate automatically (see OAuth flow). API-key vendors rotate by hand:

  1. 1

    Generate a new key on the vendor

    Log into the vendor dashboard, create a new API key. Don't revoke the old one yet.

  2. 2

    Save the new key

    Call save-integration with the new key in credentials. Subsequent vendor calls use the new key immediately.

  3. 3

    Smoke-test

    Call POST /upstream/test/:integration/:operation to confirm the new key works.

  4. 4

    Revoke the old key

    In the vendor dashboard, delete the previous key.

The platform doesn't store key history — there's no rollback to a previous key. If the new key is bad and you've already revoked the old one, the integration goes down until you create another.

Revocation

POST/upstream/shutdown/:idJWT

Shutdown does three things in order:

  1. Revoke at the vendor where the API supports it (OAuth grants are revoked, Stripe restricted keys deactivated). Vendors that don't expose a revoke API are skipped.
  2. Zero the credential blob in storage (overwrite with random bytes, then mark deleted).
  3. Move the integration to status: "inactive". Subsequent Upstream calls fail with "vendor not configured".

Shutdown is idempotent — calling it twice is safe.

After shutdown, the platform cannot recover the credential. Reconnecting requires going through the vendor's connect flow again. If you only want to pause, set status: "paused" via update — calls will fail but the credentials remain.

Webhook secrets

Vendors that send webhooks (Stripe, Twilio, Facebook, SendGrid, Mailgun) include a signature header AppEngine verifies before accepting the event. The webhook secret is part of the credential blob; when a webhook arrives at /connect/webhook/:vendor, the Connect module looks up the integration by org and verifies the signature. Missing or wrong signatures return 400 — the event is not processed.

If you change a webhook secret in the vendor dashboard without updating the integration, all webhooks start failing silently from the vendor's side until you save the new secret.

Multi-environment

For dev/staging/prod separation, create separate integration records with descriptive names: Stripe Production, Stripe Test, Twilio Sandbox. Each has its own credentials. Application code picks one by the integration ID or by the use-case routing rules — see overview.

Audit

Every credential save, rotate, and shutdown writes an entry in the org's audit log via the activity-tracking layer. Logs include actor, IP, timestamp, and the type of change (but never the credential value). Use GET /crm/activity/by-resource/integration/:integrationId to retrieve the audit trail.

What is not encrypted

publicConfig is plain JSON. Anything you put there is visible to anyone with read access on the integration record. Don't put secrets in publicConfig because the field is convenient — use the credentials field always.