Skip to content
campaiDeveloper
campai.com

Developer apps already have helpers for calling campai, storing app data, uploading files, and connecting a widget to its server. You do not install an SDK or paste an API key into the editor.

Start after creating your first developer app. The server examples below are complete TypeScript entry files for the hosted app editor. They are not Node.js programs or external REST requests. For external integrations, use the cURL, Node.js, and PHP examples in the API reference.

What you’ll have at the end

Working hosted-app examples that read a member count, persist app state, protect concurrent updates, and save a widget display preference.

  1. Choose the server or client project
  2. Use the supplied helper
  3. Await and check the result
  4. Verify in simulation
Before you start
  • An unpublished developer app and a Schedule group for manual simulations
  • A test mandate with scoped Mandates Read and Members Read for the API example
  • Database access for storage examples; a Widget group for the client example
On this page
Object Where it runs Use it for
api Server project Typed campai API operations, authenticated with the app's managed credential.
storage Server project App database entries, atomic updates, and file uploads.
ObjectId Server project Validate, construct, and compare ObjectId values.
console Server and widget client Small diagnostic messages visible during simulation; server runs also have app logs.
process.env Server project Runtime configuration and configured secret placeholders. Never return these values to a browser.
fetch Server project Outbound HTTP requests to permitted destinations. It does not automatically authenticate an arbitrary service.
action Widget client Call a named export in that widget's server entry file.
widgetStorage Widget client Browser-local preferences through get, set, and remove.

api, storage, and ObjectId are supplied as server globals. The editor's completion shows their available methods and types. action and widgetStorage are supplied to the widget client. Moving a snippet to the other project does not move its permissions or make those objects available there.

Create or reuse an unpublished Schedule group and select its server index.ts. Use manual simulation for these examples. In Settings → Server permissions → Edit server access, configure Mandates → Read and Members → Read. This managed-key grant applies across the organization; the app settings have no mandate restriction selector. Set the intended test mandate explicitly in the server code below. See Configure server access and secrets for the settings and their effect on published apps.

Replace YOUR_MANDATE_ID with the full mandate ID. This entry file reads a member count without retrieving member records:

Example

export async function main() {
    const result = await api.crm.contacts.listContacts({
        mandateId: 'YOUR_MANDATE_ID',
        types: ['member'],
        limit: 0,
        offset: 0,
        returnCount: true,
    })

    if (!Number.isSafeInteger(result.count) || result.count < 0) {
        throw new Error('Expected a nonnegative member count')
    }

    console.log({ memberCount: result.count })
}

Choose Save changes → Simulate → Start simulation. For a mandate with seven matching member contacts, the log contains memberCount: 7. Zero is also a valid successful result.

The method takes one parameter object. Put path, query, and body fields in that object; the helper builds the request and returns the parsed response. Do not add a nested body, call result.json(), or pass an X-API-Key header. The organization defaults to the app's organization. A missing mandate defaults to the runtime's all-mandates scope, so specify mandateId when your workflow is limited to one mandate.

The operation is List contacts. For a real member export, use a positive page size, advance offset, and process all pages. limit: 0 here is intentionally count-only. The managed credential still needs the operation's permissions; a built-in client does not bypass access checks.

Enable Database access in the app's server settings before using database methods. This grants access to the app's own key-value database, not arbitrary campai collections. The app's groups share that app database, so give keys a meaningful namespace.

Replace the Schedule entry file with this independent round-trip example. It writes only a synthetic preference under a dedicated guide key, reads it, and deletes that key afterwards:

Example

export async function main() {
    const key = ['guide-runtime-roundtrip', 'preference'] as const
    try {
        await storage.set(key, { layout: 'compact', savedAt: new Date() })

        const entry = await storage.get<{ layout: string; savedAt: Date }>(key)
        if (entry.versionstamp === null || entry.value === null) {
            throw new Error('The preference was not found')
        }

        console.log({
            layout: entry.value.layout,
            savedAt: entry.value.savedAt.toISOString(),
            hasVersion: entry.versionstamp !== null,
        })
    } finally {
        await storage.delete(key)
    }
}

Expect layout: "compact", an ISO timestamp, and hasVersion: true. Use this key only for the guide exercise; the cleanup removes any value at that key. Simulation performs real storage operations, so do not substitute a key containing existing application state.

storage.get() returns an entry, not the stored value directly: read entry.value. A missing key has both value: null and versionstamp: null. A stored null has a versionstamp, so check the versionstamp when you need to distinguish the two. The generic type documents your expectation; it is not runtime validation of old or untrusted data.

Method Important detail
get(key) Returns { key, value, versionstamp }. Keys are nonempty arrays, such as ['jobs', 'member-count'].
getMany(keys) Returns entries for several keys, including missing entries.
set(key, value) Replaces the value at that key. Await the write.
delete(key) Removes that key; the method is called delete, not remove.
list({ prefix }, { limit }) Returns an async iterator. Use for await, a bounded limit, and its cursor when continuing later.
atomic() Builds conditional changes; nothing is committed until commit(). Check the returned ok.

Server storage supports serializable values including nested objects, arrays, Date, and ObjectId. Do not store functions, request objects, or open connections. Store secrets in the app's secret configuration, not in an ordinary database entry.

For a read-modify-write operation, compare the entry's versionstamp before replacing it. This standalone Schedule example counts manual guide runs without silently losing an update when two executions overlap:

Example

export async function main() {
    const key = ['guide-runtime-counter'] as const
    const previous = await storage.get<number>(key)
    const count = previous.versionstamp === null ? 0 : previous.value

    if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0 || count >= Number.MAX_SAFE_INTEGER) {
        throw new Error('The guide counter has an unexpected value')
    }

    const result = await storage.atomic()
        .check({ key, versionstamp: previous.versionstamp })
        .set(key, count + 1)
        .commit()

    if (!result.ok) {
        throw new Error('Another run changed the counter; read it again before retrying')
    }

    console.log({ completedGuideRuns: count + 1 })
}

With a new key, two sequential simulations log 1 and then 2. The value persists between runs. A conflict produces an error instead of a false success. When finished, remove only ['guide-runtime-counter'] with storage.delete in your test app; do not clear the entire app database.

An atomic storage commit covers its database operations. It cannot also make an invoice creation or an external HTTP request atomic. Do not retry an entire workflow with external effects just because a checkpoint conflicts. See Handle API errors and retries.

File uploads are different from database values. storage.uploadFile() obtains an upload URL, uploads the supplied bytes, and returns a resource id with a temporary download url. Database access is for the database methods; file operations use the app's API access to the storage endpoints.

This independent Schedule entry uploads a small text file. Running it creates a new file resource each time:

Example

export async function main() {
    const bytes = new TextEncoder().encode('Reference: GUIDE-001\n')
    const file = await storage.uploadFile(bytes, {
        contentType: 'text/plain',
        contentDisposition: 'attachment; filename="guide-reference.txt"',
        expiresInSeconds: 300,
    })

    console.log({ resourceId: file.id, downloadAvailable: Boolean(file.url) })
}

Expect a resource ID and downloadAvailable: true. The five-minute expiry above applies to the generated download URL; it is not a promise to delete the file after five minutes. Treat a signed URL as access to the file and avoid logging it for sensitive content.

For larger inputs, storage.uploadStream() accepts a byte stream or an iterable of Uint8Array chunks. The helper manages multipart upload. Neither upload method attaches the resource to a contact or receipt. Pass file.id to the destination endpoint's documented file field, with its own permissions; see Upload and attach files.

The contact-review dashboard provides the complete server action and React interface. action('getReviewSummary') invokes that named server export and returns its result to the client. The server reads storage; the client receives only counts and a timestamp.

Use widgetStorage for a harmless display preference. This complete Widget → index.tsx lets an administrator choose whether the review dashboard should show detailed counters and keeps that preference in the current browser. It needs no server action or database permission:

Example

import { useState } from 'react'

export default function Widget() {
    const [details, setDetails] = useState(true)
    const [message, setMessage] = useState('Choose how much detail your dashboard should show.')
    const [busy, setBusy] = useState(false)

    async function save() {
        setBusy(true)
        try {
            await widgetStorage.set('review-show-details', details)
            setMessage('Display preference saved in this browser.')
        } catch {
            setMessage('Could not save the preference. The current view is still usable.')
        } finally {
            setBusy(false)
        }
    }

    async function load() {
        setBusy(true)
        try {
            const value = await widgetStorage.get<unknown>('review-show-details')
            setDetails(typeof value === 'boolean' ? value : true)
            setMessage(typeof value === 'boolean' ? 'Saved preference loaded.' : 'No saved preference; showing details.')
        } catch {
            setMessage('Browser preferences are unavailable.')
        } finally {
            setBusy(false)
        }
    }

    return <main className="space-y-4 p-6">
        <h1 className="text-xl font-semibold">Review display settings</h1>
        <label className="flex items-center gap-2">
            <input type="checkbox" checked={details} disabled={busy} onChange={event => setDetails(event.target.checked)} />
            Show missing-email and address counters
        </label>
        <p role="status">{message}</p>
        <button disabled={busy} onClick={save}>Save display preference</button>
        <button disabled={busy} onClick={load}>Load saved preference</button>
    </main>
}

Uncheck the box, save, restart the widget simulation, and load. The checkbox should become unchecked again. In a new browser without that preference, Load uses the default of showing details. A storage failure produces a message while the current local choice remains usable.

To apply this choice to the dashboard, use the same state and load/save functions there, and render the two detailed counter paragraphs only when details is true. Keep the total and last successful refresh visible regardless of this preference.

widgetStorage lives in the host browser's local storage under the widget group namespace. It is not the server app database, is not a per-member authorization boundary, and does not synchronize between browsers or devices. A visitor can modify or clear it. Use it for preferences, never credentials, member records, payment state, or proof of access. widgetStorage.remove('review-show-details') removes this preference; server storage uses delete instead.

Use full IDs from API responses. The server's ObjectId.isValid(value) checks the ObjectId shape; new ObjectId(value) constructs it, .toHexString() produces its string form, and .equals(other) compares values. Two separately constructed objects are not equal with ===. A validly shaped ID does not prove that a record exists or that a visitor may access it. File resource IDs can use a different format and must not be forced into ObjectIds.

process.env also exposes configured secret placeholders. These are injected into permitted outbound HTTPS traffic; they are not plaintext secrets to inspect or hash. Use Server access and secrets for allowed hosts and secret configuration. Never copy the managed API credential into a widget, action result, log, or source file.

Symptom Check
api or storage is unavailable Select the server project. A widget client uses action to reach server code.
API access error or 403 App server permissions, the explicit mandate, and the endpoint's required scopes.
Database operation is denied Database access is enabled for this app; contact permissions alone are insufficient.
A property is missing after storage.get Read entry.value, and handle versionstamp: null before using it.
Counter did not commit Check result.ok; reload the entry after a conflict.
Browser preference disappeared Check browser/site storage and the widget group. Do not treat it as durable server state.
Saved code behaves differently when shared Publish the intended version and test its client Scope and server settings.

Await every operation before returning. Catch errors only when you can handle them; otherwise let the simulation report the failure. Log operation names and small summaries, not complete records, secrets, signed URLs, or raw sensitive error bodies. Continue with Create a custom widget or Run an app on a schedule once the individual helpers behave as expected.

Check your result

API simulation
Logs a nonnegative memberCount without retrieving contact records.
Storage round-trip
Logs compact, a timestamp, and hasVersion: true; deletes only the exercise key.
Counter run twice
A new counter reports 1 then 2; a conflicting commit reports an error.
Saved display choice
Uncheck, save, restart, and load: the checkbox becomes unchecked again.
No browser storage
The widget reports the storage failure and keeps the current display choice usable.
Continue with Create a custom widget