The performance principles
Six principles, in rough order of impact:| Principle | Description |
|---|---|
| Use webhooks for change detection, not polling | Webhooks deliver changes within seconds; polling at any reasonable cadence both misses changes and wastes budget |
| Use the largest page size the endpoint supports | Each request consumes one rate-limit slot regardless of records returned |
| Cache reference data aggressively | Campaigns, Projects, MotivationCodes, CustomFields change rarely — caching them eliminates most reads |
| Push filters into the API rather than client-side | Filtering 500 records server-side is far cheaper than reading 50,000 and discarding 49,500 |
Use IncludeDetails=false for bulk reads | Default summary payloads are dramatically smaller than detail payloads |
| Choose the right endpoint for the job | /list is cheaper than /query; donor-scoped endpoints are cheaper than filtered queries |
Principle 1: webhooks over polling
For any change-detection workflow — syncing new gifts, reacting to donor updates, processing recurring failures — webhooks deliver the change within seconds and consume zero rate-limit budget for the detection itself. Polling at any reasonable cadence is strictly worse.The polling trap
A typical naive polling pattern:JavaScript
- 288 requests per day per customer just for change detection. For a partner with 100 customers, that’s 28,800 daily requests producing no user-facing value.
- 5-minute lag between gift creation and detection.
- Edge cases at the boundaries — gifts created exactly between polls can be missed or double-counted.
- No insight into deletions or updates unless you also poll modification timestamps.
The webhook alternative
JavaScript
- One subscription per customer, zero ongoing rate-limit cost for the detection.
- Seconds-level latency instead of minutes.
- All event types covered — creates, updates, deletes — with no extra work.
- Edge cases handled by Raise — retries, ordering, etc.
When polling is still appropriate
Three legitimate cases for polling:| Case | Description |
|---|---|
| Periodic reconciliation backstop | Daily or hourly query to catch any events the webhook may have missed |
| Initial backfill | One-time pull of historical data when an integration first connects to a customer |
| Data that doesn’t produce webhook events | If a particular state change you care about doesn’t fire an event, polling may be the only path |
Principle 2: use the largest page size
For paginated reads, each request consumes one rate-limit slot regardless of how many records it returns. ATake=1000 request returns 1,000 records for the cost of one request; a Take=25 request returns 25.
When this matters most
| Workflow | Take size impact |
|---|---|
| Bulk export | 40× fewer requests with Take=1000 vs. Take=25 |
| Reconciliation queries | Same — bulk reads benefit dramatically |
| Initial backfill | Same — typically the largest workload |
| Interactive UI list views | Smaller pages (25–50) load faster for users |
Take=1000 and only reduce if a specific endpoint enforces a lower maximum. The pagination loop is otherwise identical.
A bulk read pattern
JavaScript
Take=1000 produces 100 requests. The same read with Take=100 produces 1,000 requests. The same data, 10× the rate-limit cost.
Principle 3: cache reference data
Some Raise resources change rarely — Campaigns, Projects, MotivationCodes, Premiums, CustomFields. Caching these eliminates the majority of read traffic for analytics and reporting integrations.What to cache
| Resource | Typical change frequency | Recommended cache TTL |
|---|---|---|
| Projects | Rare (a few times per year) | 1 hour |
| Campaigns | Occasional (a few times per quarter) | 1 hour |
| MotivationCodes / MotivationCodeGroups | Rare | 1 hour |
| CustomFields | Rare | 1 hour |
| Premiums | Occasional | 1 hour |
| EmailLists | Rare | 1 hour |
| Gateways and similar metadata | Very rare | 24 hours |
| Query options (integer-to-label mapping) | Effectively never (until v2) | 24 hours |
A reference-data cache pattern
JavaScript
Cache invalidation strategies
| Strategy | When to use |
|---|---|
| Time-based (TTL) | The simplest pattern; works for resources that change infrequently |
| On-demand refresh | If a recent fetch returns a record the cache doesn’t have, refresh the cache |
| Manual invalidation | When the partner integration triggers a known change (e.g., creates a new Project), invalidate the cache immediately |
Query options caching
The QueryOptions discovery endpoint (GET /api/Query/options/{queryType}) returns the integer-to-label mapping for query operators and filter parameters. This effectively never changes — cache it at integration startup and refresh once a day at most:
JavaScript
Principle 4: push filters into the API
A query that returns 500 records after filtering is much cheaper than one that returns 50,000 records the integration then filters client-side.Filter at the source
Bad pattern — pull everything, filter in code:JavaScript
JavaScript
Combine multiple filters
Filters compose with AND semantics within a condition group. Combining narrows the result set further:JavaScript
Use /list + Filter for free-text matching
For interactive search where the user types a query string, the simple Filter parameter on /list is cheap and fast:
JavaScript
POST /api/Donor/query for what’s essentially a search-as-you-type lookup — /list with Filter handles it with less overhead.
Principle 5: use IncludeDetails=false for bulk reads
IncludeDetails=true includes related entities (addresses, contact methods, embedded donor on gifts, etc.) in each response item. For bulk reads, this can multiply the payload size 5–10×.
The Raise spec calls this out explicitly on POST /api/Donor/query:
When includeDetails=true, the response includes all related entities (DonorAddresses, DonorContactMethods) similar to the GET by ID endpoint. This may impact performance for large result sets.
Use it sparingly
When to use IncludeDetails=true | When to use IncludeDetails=false |
|---|---|
| Exporting full donor records to a downstream system | Iterating donors for aggregation |
| Displaying full donor cards in a UI | Building a list of donor IDs for batch operations |
| One-off lookups of a specific record | Steady-state sync workflows |
| Detail views requiring all sub-resources | Reconciliation queries |
IncludeDetails=false. Switch to true only when the workflow demonstrably needs the related entities and the result set is small (typically a few hundred records at most).
When you do need details on many records
For workflows that genuinely need full records across many donors, fetch them individually rather than as a bulk read:JavaScript
IncludeDetails=true, but each is small and the workload is more controlled. For very large workloads, prefer the controlled-concurrency pattern over the bulk-with-details one.
Principle 6: choose the right endpoint
Different endpoints have different costs for the same logical question. Some examples:Donor’s gifts: scoped endpoint vs. filtered query
| Pattern | Endpoint |
|---|---|
| ✅ Donor-scoped | GET /api/Donor/{donorId}/gifts |
| ❌ Filtered query | POST /api/Gift/query with donorId filter |
Donor lookup by email: search vs. query
| Pattern | Endpoint |
|---|---|
| ✅ Interactive search | GET /api/Donor/search?filter=<email> |
| ❌ Structured query for a single lookup | POST /api/Donor/query with a single email condition |
/search is optimized for free-text lookups and is faster than the structured query for single-record matches.
Reading reference data: list vs. query
| Pattern | Endpoint |
|---|---|
| ✅ Bulk list | GET /api/Project/list?Take=1000 |
| ❌ Query for everything | POST /api/Project/query with no filter |
/list is cheaper than /query.
Reading a single record: get-by-ID vs. filtered query
| Pattern | Endpoint |
|---|---|
| ✅ Direct fetch | GET /api/Donor/{donorId} |
| ❌ Single-record query | POST /api/Donor/query with an ID condition |
Avoiding common performance traps
A few specific anti-patterns that cause performance issues:Sequential fetches when parallel would do
JavaScript
JavaScript
Repeated lookups of the same data
JavaScript
JavaScript
Synchronous webhook processing
JavaScript
JavaScript
Fetching unnecessary fields with selectedColumns
When you don’t need every field, request a subset:
JavaScript
selectedColumns reduces response payload by an order of magnitude. The set of valid columns is discovered via QueryOptions.
Measuring performance in production
For partner integrations operating at scale, instrument for visibility:Track per-endpoint latency
JavaScript
Track request count per customer
JavaScript
Track cache hit rate
JavaScript
Alert on rate-limit responses
If429 responses occur (the spec doesn’t formally document them but they happen), alert immediately:
JavaScript
A performance checklist
Run through this when building or auditing an integration:- Change-detection uses webhooks, not polling, wherever possible
- Bulk reads use
Take=1000 - Reference data (Campaigns, Projects, MotivationCodes, CustomFields, QueryOptions) is cached with appropriate TTLs
- Filters are applied in the API request, not in client-side filtering of large result sets
- Bulk reads use
IncludeDetails=false selectedColumnsreduces payload when only a few fields are needed- Donor-scoped endpoints are used for donor-scoped reads (not filtered queries)
- Get-by-ID is used when the ID is known (not filtered queries)
- Concurrent fetches use a controlled-concurrency pool
- Webhook handlers acknowledge fast and process async
- Latency, request count, cache hit rate, and rate-limit responses are instrumented
Where to go next
Error Recovery Patterns
What to do when the techniques on this page meet the reality of transient failures.
Rate Limits
The throttling patterns that pair with the performance practices.
Pagination and Filtering
The reference for the pagination, filtering, and
selectedColumns mechanics used here.Sync Architecture Patterns
The broader architectural patterns these performance techniques fit into.