Data Connectors

Build a Stripe Data Connector

Build a Stripe test-mode data connector extension with customers, products and prices, from probing the API to writes and tests.
Preview
Extensions are in preview and can change in breaking ways between releases. Use them in non-production environments only. See Stability. Written for Monospace 1.0.0, @monospace/cli 0.2 and @monospace/extension-kit 0.3.

Overview

This guide builds a data connector extension for Stripe in test mode. The connector exposes the Stripe API, its external data source, as three collections in a Monospace workspace. Each item in customers is one Stripe customer, with fields such as email.

Stripe is a harder case than the Quickstart's Art Institute API: it has no filter language, needs a secret key, and accepts writes. The same patterns serve any external data source with a weak grammar.

CollectionReadsWrites (with enableWrites)
customersFilter by id, email and created; newest firstCreate, update, delete
productsFilter by id, active and created; newest firstCreate (optionally with your own id), update, delete
pricesFilter by id, productId, active, type and created; newest firstCreate; update active and nickname only. Stripe can't delete prices

A relation links prices.productId to products.id. A price has a to-one product field, which returns a single related item, and a product has a to-many prices field, which returns several.

Download
Download the finished project (stripe-connector.zip): the eleven source files, the unit tests, and the seed and smoke scripts.

This guide explains the decisions behind those files and shows only the code that carries a Stripe-specific decision.

Prerequisites

RequirementCheck
A Stripe test-mode secret keyThe key starts with sk_test_
A self-hosted Monospace instance (a running Monospace deployment) whose extensions install location you can write toSee Install and Manage Extensions
Node.js 22.12 or later and a package managernode --version
A Monospace API key for a user who can create data sources and read and write items, such as a workspace AdministratorCreate one under Account Settings > Access in Studio. See Get an API Key

Build the Data Connector Quickstart first if you haven't built a connector before. This guide explains its concepts only briefly.

Code blocks on this page are excerpts of the files in the download, and // … marks the lines an excerpt leaves out. Each step ends with a Done when list: run those checks against the complete project.

Query results are cached. When you compare a response with Stripe after a change, send Cache-Control: no-cache.

1. Probe the Stripe API

Design against what Stripe does, not what its reference says. Call every endpoint and parameter you might rely on, and record what comes back:

terminal
curl -g "https://api.stripe.com/v1/customers?limit=3&offset=5" \
  -H "Authorization: Bearer sk_test_YOUR_STRIPE_KEY" \
  -H "Stripe-Version: 2026-08-26.dahlia"

Probe each filter with a value that must match nothing, and probe unknown parameters, sorts, page sizes, counts and error paths. The connector's design rests on these results:

ProbeResult
customers?sort=created, customers?foo=1Refused. Stripe rejects unknown parameters, and lists return newest created first with no other order
limit=101100 objects and has_more: true, no error. The page size is capped at 100 without warning
customers?limit=3&offset=5Objects 6 to 8. Stripe's list endpoints also accept an undocumented offset parameter; its reference documents only cursors
include[]=total_countRefused. Lists have no total count
email=ada@… vs. email=ADA@…1 vs. 0 customers. The email filter is exact and case-sensitive
created[gte]=t&created[lte]=tInclusive, in whole seconds
prices?type=bogusHTTP 400. Enum parameters reject values outside their domain
prices?product=prod_does_not_existHTTP 400, code: resource_missing, param: product: an error, not an empty list
GET customers/cus_missingHTTP 404, code: resource_missing, param: id
DELETE products/{id} for a product with pricesHTTP 400, type: invalid_request_error, with no code and no param
A wrong keyHTTP 401. The message echoes the key's last characters
A 400 or 404 error bodyCarries a request_log_url, which names the Stripe account
A deleted customer by idStill returned, with deleted: true

Turn the results into a capability table. Every filter operator, sort and paging argument you offer names the Stripe request that answers it. If nothing answers it, you don't offer it.

CollectionOfferedStripe request
allid: equals, inGET /{collection}/{id}, one per id
allcreated: equals, lessThan[OrEquals], greaterThan[OrEquals]created[gte], created[lte] in whole seconds
allsort created descending; limit, offsetthe list endpoint, paged by 100
customersemail: equals, in?email=…, one request per value
productsactive: equals?active=true or ?active=false
pricesproductId: equals, in; active: equals; type: equals, in?product=…, ?active=…, ?type=…

Filters on name, phone, unitAmount and most other fields aren't in the table. Stripe has no list parameter for them, and answering one would mean reading every item. Total counts, other sort orders and negation are out for the same reason.

Done when every entry in the table names a Stripe request you ran, and you know which entries match exactly. See Map an External Data Source for the full probing checklist and the Fit Check.

2. Set Up the Project

Unzip the download and install its dependencies, or scaffold a project with the CLI and add the files yourself:

npx @monospace/cli@0.2 extension create ./stripe-connector --id example/stripe --name Stripe

The CLI prints Created the Stripe extension and two Next steps. Run the first, which installs the dependencies. The scaffold puts its source in src/data-connectors/example/: delete that directory, because this connector's source goes in src/data-connectors/stripe/.

Give the entry its own id, so a second extension from the same namespace doesn't collide with it. This is the finished extension config:

extension.config.json
{
    "$schema": "./node_modules/@monospace/cli/schemas/extension.config.schema.json",
    "id": "example/stripe",
    "version": "0.1.0",
    "name": "Stripe",
    "icon": "simple-icons:stripe",
    "iconBackground": "#635BFF",
    "iconForeground": "#ffffff",
    "entries": [
        {
            "type": "dataConnector",
            "id": "example/stripe-connector",
            "version": "0.1.0",
            "name": "Stripe",
            "icon": "simple-icons:stripe",
            "iconBackground": "#635BFF",
            "iconForeground": "#ffffff",
            "engine": {
                "capabilities": { "query": ["insertReturning", "updateReturning", "deleteReturning"] },
                "entrypoint": "./src/data-connectors/stripe/index.ts",
                "permissions": [
                    { "permission": "net:api.stripe.com", "reason": "Read and write customers, products and prices through the Stripe API" }
                ]
            }
        }
    ]
}
  • permissions: net:api.stripe.com is the only host the connector calls.
  • capabilities: the connector answers every write with the items it wrote. See Declare What Writes Return.

See Extension Config and Manifest for every field.

The project's tsconfig.json turns on noUncheckedIndexedAccess and exactOptionalPropertyTypes, which the kit type-checks under. The connector is split by what changes together:

FileJob
index.tsParses configuration, builds the client, and dispatches each operation
config.tsValidates the configuration and applies defaults
client.tsEvery Stripe request: deadline, paging, and error mapping
collections.tsThe declared collections and their relation
resources.tsThe Stripe mapping: paths, form keys, list parameters
plan.tsTurns a filter into Stripe requests, or refuses it
filter.tsEvaluates a filter in memory, and parses timestamps
items.tsConverts between Stripe objects and items
read.tsreadMany, readOne, keyed lookups, and the fetch budget
write.tsEvery write operation
concurrency.tsRuns a fan-out with at most four requests in flight

A new collection touches its declaration in collections.ts and its mapping in resources.ts. Keeping planning apart from execution lets you test which requests a filter produces without calling Stripe.

Build the project and install it into your instance's install location:

npm run build -- --install-to ../monospace/extensions
output
example/stripe-connector  example/stripe-connector.js
Artifact                  …/stripe-connector/dist
Installed                 …/monospace/extensions/example__stripe
Restart the engine to load it, unless it runs with MONOSPACE_EXTENSIONS__AUTO_RELOAD=true.

Done when the build installs as example__stripe/ and the instance lists example/stripe-connector in GET /api/{workspace}/connectors/data. An extension can load and still contribute no connector, so check this list, not only the log. See Install and Manage Extensions.

3. Parse Configuration and Build the Client

A data source's configuration reaches setup as unchecked JSON. The Stripe connector reads three keys:

KeyTypeDefaultMeaning
apiKeystring(required)A secret (sk_) or restricted (rk_) key, test or live mode
enableWritesbooleantrue for a test-mode key, false otherwiseDeclares and allows writes
maxItemsPerQuerypositive integer5000The most Stripe objects one read may fetch before the connector refuses it. See Refuse What You Can't Answer

parseConfig in config.ts returns typed configuration or throws InvalidConfiguration:

  • A malformed key fails before any request. Anything not shaped like a Stripe secret or restricted key is refused, such as a publishable key.
  • A wrong key passes setup. Stripe rejects a well-formed key it doesn't recognize with a 401, on the connection test or the first query.
  • Writes default to off for live keys. A live key gets a read-only data source unless its configuration turns writes on.

index.ts builds the client once in setup and dispatches each operation to its handler. setup makes no requests, so it stays fast and can't fail on a network error. testConnection lists one customer, so a key Stripe rejects fails the connection test. There's no preflight, because the only host is fixed and declared in the extension config.

Map Every Stripe Failure

The client owns every Stripe request. Its one fetch call pins the API version and sets a 10-second deadline, because Engine sets none (see Per-Request Deadline). It keeps a rejected request as the cause of its ConnectionFailed, so a request the data source's permissions refused reaches the caller as a permission error.

toConnectorError classifies every error response. It reads Stripe's error body by its code and param, after special cases for rejected keys and rate limits:

src/data-connectors/stripe/client.ts
function toConnectorError(response: StripeResponse, method: Method, path: string): Error {
    const detail = readError(response.body);
    const { status } = response;
    // The path without the query string, which can hold the caller's filter values.
    const message = detail.message ?? `Stripe answered HTTP ${status} to ${method} ${new URL(path, BASE_URL).pathname}.`;

    // Only someone with access to the Stripe account can fix the key. Stripe's message for a rejected
    // key echoes part of the key, so it stays out.
    if (status === 401 || status === 403) return new AccessDenied(`Stripe rejected the API key (HTTP ${status}).`);
    if (status === 429) return new RateLimited(`Stripe rate limited the connector.${retryAfter(response.headers)}`);

    switch (detail.code) {
        case 'resource_missing':
            // `param` names what is missing: `id` for the object in the path. On a write, any other
            // field is an object the written values refer to, such as a new price's `product`.
            if (method === 'POST' && detail.param !== undefined && detail.param !== 'id') return new ForeignKeyConstraintViolation(message);
            return new StripeObjectMissing(message);
        case 'resource_already_exists':
            return new UniqueConstraintViolation(message);
        case 'parameter_missing':
            return new NullConstraintViolation(message);
    }

    // Stripe refuses to delete a product that still has prices with no error code, only a message.
    if (method === 'DELETE' && detail.type === 'invalid_request_error' && /\bprices\b/u.test(message)) {
        return new ForeignKeyConstraintViolation(message);
    }

    // Treat a server error as an outage: a later request may work.
    if (status >= 500) return new ConnectionFailed(message);
    // Stripe answers 400 to values it refuses, such as an invalid email: the caller can change them.
    if (status === 400 && detail.type === 'invalid_request_error') return new QueryRejected(message);
    return new Error(message);
}
  • A rate limit is RateLimited. Stripe answers too many requests per second with a 429. The message adds "Retry after 2 seconds." when Retry-After is a whole number of seconds.
  • A plain Error is a failure the caller can't fix, such as a changed API. Engine reports it as a failed query. StripeObjectMissing is one, which retrieve and the keyed writes answer as a miss instead.
  • Only the error's code and message reach Engine. So no message carries the key, the query string, or the account's request_log_url. Stripe's own message passes through, and it can quote a value the caller sent.

See Handle Data Connector Errors for choosing error classes and Data Connector Errors for what each one does.

Test the connection with the permissions included. The SDK has no method for data sources, so call the endpoint directly:

curl -X POST https://example.monospace.io/api/blog/sources/data/test \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "example/stripe-connector",
    "config": { "apiKey": "sk_test_YOUR_STRIPE_KEY" },
    "permissions": [
      { "permission": "net:api.stripe.com", "reason": "Read and write customers, products and prices through the Stripe API" }
    ]
  }'

A successful test answers 204 with an empty body. A wrong key fails with 422, and the error nests the connector's message: "Stripe rejected the API key (HTTP 401)."

Done when:

  • setup throws InvalidConfiguration for {}, for a publishable key such as pk_test_123, and for "maxItemsPerQuery": 0, without making a request;
  • a connection test without permissions answers 422 with code 7001, then succeeds with the returned set (see Network Permissions);
  • a connection test with a wrong key fails, and one with your test key succeeds.

4. Declare the Schema and Create a Data Source

Stripe has no metadata API, so the connector declares its collections statically, pinned to one Stripe API version. The filters come straight from the capability table:

src/data-connectors/stripe/collections.ts
/**
 * Stripe has no metadata API, so its objects are declared here and pinned to one API version:
 * `STRIPE_API_VERSION` and the field paths in `resources.ts` change together.
 */
export const STRIPE_API_VERSION = '2026-08-26.dahlia';

// The filters below declare only what Stripe answers exactly, and `plan.ts` sends a filter to
// Stripe only when it's declared here: `id` by retrieving it, `created` through `created[gte]` and
// `created[lte]`, and every other field through its list parameter in `resources.ts`.
const exact = filter.field('equals', 'in');
/** Engine executes only `equals` on booleans. */
const booleanExact = filter.field('equals');
const createdRange = filter.field('equals', 'lessThan', 'lessThanOrEquals', 'greaterThan', 'greaterThanOrEquals');
const andOr = filter.logical('and', 'or');
/** Stripe lists newest first and can't sort any other way. */
const newestFirst = { created: sort.field('descending') };

// Values Stripe sets itself: callers never have to supply them on create.
const setByStripe = { defaultValue: defaultValue.connectorManaged() };
const id = field.string(setByStripe);
const created = field.dateTimeWithTimezone(setByStripe);
const nullableText = field.string({ nullable: true });

// A field is writable exactly when a `data` list below names it: the kit derives the read-only
// flags from these lists. `balance` and `unitAmount` are 64-bit integers, which travel as strings.
const customerFields = {
    id,
    name: nullableText,
    email: nullableText,
    phone: nullableText,
    description: nullableText,
    balance: field.int64(setByStripe),
    created,
};
const customerFilter = { fields: { id: exact, email: exact, created: createdRange }, logicalOperators: andOr };
const customerData = fieldsOf(customerFields).pick('name', 'email', 'phone', 'description', 'balance');

export const customers = defineCollection({
    name: 'customers',
    fields: customerFields,
    indexes: { customers_pkey: index.primary(['id']) },
    operations: {
        readMany: { filter: customerFilter, sort: newestFirst, limit: paging.limit(), offset: paging.offset(), output: fields.all() },
        readOne: { filter: customerFilter, output: fields.all() },
        create: { data: customerData, output: fields.all() },
        updateMany: { filter: customerFilter, data: customerData, output: fields.all() },
        updateOne: { filter: customerFilter, data: customerData, output: fields.all() },
        deleteMany: { filter: customerFilter, output: fields.all() },
        deleteOne: { filter: customerFilter, output: fields.all() },
    },
});
  • Sort is created descending only, the one order Stripe lists in. No collection declares meta.totalCount, because Stripe lists have no total count.
  • One read filter is reused by every member. Engine requires write filters to stay within readMany.filter.
  • Values Stripe sets carry a connectorManaged default, so callers never have to supply id, created or balance on create.
  • products and prices follow the same pattern. A product's create also takes a caller-chosen id, and prices declares no deletes, because Stripe can't delete prices.

schemaDocument(enableWrites) passes writes: enableWrites to the kit's toSchemaDocument. With writes off, every writing operation is dropped, and Engine refuses a write before it reaches the connector.

defineSchema also declares the prices_product relation. Engine resolves each direction of a relation by filtering the other collection by its key field. Both products.id and prices.productId declare in, so both directions work. See Resolve Relations.

Map Fields to Stripe

The query code also needs each field's place in a Stripe object, its form key for writes, and its list parameter. resources.ts keeps that mapping, keyed by the declared field names, so tsc reports a typo:

src/data-connectors/stripe/resources.ts
/** Where a field lives in Stripe, when that differs from its snake_case name. */
interface FieldMapping {
    /** Location in the Stripe object. Defaults to the field's name in snake_case. */
    readonly path?: readonly string[];
    /** Form key for writes. Defaults to the field's name in snake_case. */
    readonly formKey?: string;
    /** The list parameter that filters the field by exact value. */
    readonly listParameter?: string;
    /**
     * Values the list parameter accepts. Stripe rejects an unknown enum value with an error, where a
     * filter on one matches no item, so the plan never sends other values.
     */
    readonly listValues?: RegExp;
}
// …
const BOOLEAN_VALUES = /^(?:true|false)$/u;

const RESOURCES: readonly ResourceSpec[] = [
    toSpec(customers, 'customers', {
        email: { listParameter: 'email' },
    }),
    toSpec(products, 'products', {
        active: { listParameter: 'active', listValues: BOOLEAN_VALUES },
    }),
    toSpec(prices, 'prices', {
        productId: { path: ['product'], formKey: 'product', listParameter: 'product' },
        active: { listParameter: 'active', listValues: BOOLEAN_VALUES },
        type: { listParameter: 'type', listValues: /^(?:one_time|recurring)$/u },
        recurringInterval: { path: ['recurring', 'interval'], formKey: 'recurring[interval]' },
    }),
];

toSpec joins each declaration with its mapping, so the plan and the writes never disagree with the declaration. items.ts converts values in both directions:

  • Timestamps: Stripe's Unix seconds become RFC 3339 strings.
  • Missing or unexpected values fail with a plain Error, unless the field is nullable and the value is missing.
  • Written null: an update sends an empty string, which unsets the field in Stripe. A create omits the field.
  • 64-bit integers: Engine sends int64 values, such as balance and unitAmount, as decimal strings. Callers read both fields as strings (e.g., "balance": "0").
Warning
Never check typeof value === 'number' on an int64 field or key. Engine sends a string, so such a check misses every value.

Create a data source now, before you write query code. Engine refuses a declaration that breaks its rules at create time, which is cheaper to fix before planning code depends on it:

curl -X POST https://example.monospace.io/api/blog/sources/data \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "apiName": "stripe",
    "provider": "example/stripe-connector",
    "config": { "apiKey": "sk_test_YOUR_STRIPE_KEY" },
    "permissions": [
      { "permission": "net:api.stripe.com", "reason": "Read and write customers, products and prices through the Stripe API" }
    ]
  }'

In Studio, enter the same configuration in the data source's JSON configuration editor. Add "enableWrites": false for a read-only data source.

Done when the create answers 200 with the data source's id, and prices offers every operation except deleteMany and deleteOne.

The data source keeps this schema. When you change a declaration later, rebuild and refresh the data source's schema; see Update Data Sources After an Extension Update.

5. Implement Reads

Stripe has a weak grammar: a few list parameters with fixed meanings, and no filter language. The connector plans each filter into a union of exact Stripe requests, or refuses it before sending anything.

Plan the Filter

planRequests turns a filter into list requests with parameters, or retrieves by id. It makes no requests itself:

src/data-connectors/stripe/plan.ts
/**
 * One request to Stripe. A `list` request returns exactly the items of one filter branch, in
 * Stripe's newest-first order. A `retrieve` request fetches objects by id; the caller checks the
 * rest of the filter on them in memory, which stays cheap because the ids bound the work.
 */
export type StripeRequest =
    | { readonly kind: 'list'; readonly parameters: Readonly<Record<string, string>> }
    | { readonly kind: 'retrieve'; readonly ids: readonly string[] };
// …
/**
 * Turns a filter into Stripe requests whose union is exactly the matching items. A filter Stripe
 * can't express is refused rather than answered by reading every item, unless top-level id
 * constraints bound the candidates.
 */
export function planRequests(resource: ResourceSpec, filter: FilterSerialized | undefined): StripeRequest[] {
    requireDeclared(resource, filter);
    const ids = topLevelIds(filter);

    if (ids !== undefined) {
        // The retrieved objects are re-checked against the whole filter in memory. Refuse a filter that
        // check can't evaluate now, before any request.
        requireEvaluable(resource, filter);
        return ids.length === 0 ? [] : [{ kind: 'retrieve', ids }];
    }

    let branches: Branch[];

    try {
        branches = filter === undefined ? [EVERYTHING] : toBranches(resource, filter);
    }
    catch (error) {
        if (!(error instanceof NotExpressible)) throw error;
        throw new QueryRejected(`Stripe can't filter \`${resource.name}\` by ${error.message} without reading every item.`);
    }

    // …
    if (requests.size > MAX_REQUESTS) throw tooManyRequests(resource);
    return [...requests.values()];
}
  • toBranches walks the filter tree. and intersects its children's constraints, and or and in fan out into one branch per value. A negation, a field compared with a field, or field = null can't be expressed, so the plan refuses it.
  • requireDeclared runs first. Engine sends filters to the connector without checking them against the declaration. So the connector refuses an undeclared field or operator itself, with a plain Error.
  • Values outside an enum match nothing. The plan never sends type=bogus, which Stripe would reject. It sends no request for that value instead.
  • Fan-out is bounded. A filter that plans more than 250 requests is refused with QueryRejected.

Convert Timestamps Exactly

Stripe stores created in whole seconds, and Engine sends RFC 3339 timestamps that can carry a fraction of a second. parseInstant in filter.ts parses them strictly and keeps every fractional digit. Date.parse alone would accept 2026, drop digits below the millisecond, and roll 2026-02-30 over to March 2nd.

createdBranch turns each comparison into an exact inclusive range of whole seconds:

src/data-connectors/stripe/plan.ts
/**
 * Stripe stores `created` in whole seconds, so each comparison becomes an exact inclusive range.
 * A timestamp with a fraction lies between two whole seconds: `created >= 10.5` is `created >= 11`.
 */
function createdBranch(operator: FilterOperator, value: Value): Branch {
    const { seconds, nanoseconds } = parseInstant(value);
    const hasFraction = nanoseconds > 0;

    switch (operator) {
        // A whole second never equals a timestamp with a fraction: lower > upper, so no request is sent.
        case 'equals': return { ...EVERYTHING, lower: hasFraction ? seconds + 1 : seconds, upper: seconds };
        case 'greaterThan': return { ...EVERYTHING, lower: seconds + 1 };
        case 'greaterThanOrEquals': return { ...EVERYTHING, lower: hasFraction ? seconds + 1 : seconds };
        case 'lessThan': return { ...EVERYTHING, upper: hasFraction ? seconds : seconds - 1 };
        case 'lessThanOrEquals': return { ...EVERYTHING, upper: seconds };
        default: throw new NotExpressible(`\`created\` with \`${operator}\``);
    }
}
Filter on createdt is a whole secondt has a fraction
> tcreated[gte]=t+1created[gte]=⌊t⌋+1
>= tcreated[gte]=tcreated[gte]=⌊t⌋+1
< tcreated[lte]=t-1created[lte]=⌊t⌋
<= tcreated[lte]=tcreated[lte]=⌊t⌋
= tcreated[gte]=t&created[lte]=tNo request: no item can match

A value that isn't a strict RFC 3339 timestamp is refused with QueryRejected before any request.

Page and Fill the Limit

Stripe caps a page at 100, and Engine reads a page shorter than limit as the end of the data. So the client keeps paging with starting_after until it has what the caller asked for:

src/data-connectors/stripe/client.ts
    async* list(path: string, parameters: Readonly<Record<string, string>>, offset = 0, wanted = Infinity): AsyncGenerator<StripeObject> {
        let startingAfter: string | undefined;
        let remaining = wanted;

        for (;;) {
            const query = new URLSearchParams({ ...parameters, limit: String(Math.min(PAGE_SIZE, remaining)) });
            if (startingAfter !== undefined) query.set('starting_after', startingAfter);
            else if (offset > 0) query.set('offset', String(offset));

            const response = await this.#send('GET', `${path}?${query}`);

            // Stripe answers a filter on a reference that doesn't exist, such as `prices?product=` with a
            // missing product, with `resource_missing` naming the parameter. No object can match it.
            if (startingAfter === undefined && isMissingReference(response, parameters)) return;

            const page = parseList(checked(response, 'GET', path));
            remaining -= page.data.length;
            yield* page.data;

            const last = page.data.at(-1);
            if (!page.hasMore || last === undefined || last.id === startingAfter || remaining <= 0) return;
            startingAfter = last.id;
        }
    }
  • A missing reference is no match. A productId filter naming a product that doesn't exist returns no prices, not an error.
  • The loop can't spin. It stops when Stripe has no more, the page is empty, the cursor doesn't advance, or it has what the caller wants.

With several requests, findItems in read.ts reads each list to offset + limit, merges the results newest first, and slices. Stripe doesn't document its order within one second. So each list also reads every object created in the same second as its last one, and the merge breaks ties by id. Consecutive pages then never overlap or skip an item.

Re-Check Every Item the Plan Doesn't Prove

A single list request is exact by construction, so it needs no re-check. Retrieved ids and merged fan-outs pass through matches before they're sliced. It uses SQL three-valued logic and compares timestamps as nanoseconds. See Re-Check Every Item.

Refuse What You Can't Answer

Refuse a request you can't answer correctly, before any request to Stripe when you can. Never return a silently shortened page. Pick the class by whose request it is:

  • QueryRejected for a valid request Stripe can't serve as asked, such as a negation without ids. Engine answers 422, and the message tells the caller what to change.
  • A plain Error for a request outside the declaration, such as another sort. Engine refuses those from callers, so they come from Engine itself or a schema not refreshed after an extension update. Engine answers 500.

The fetch budget covers reads Engine leaves unbounded, such as a caller's limit=-1 or a relation read (see Relation Reads). FetchBudget in read.ts counts every object a read fetches. Past maxItemsPerQuery, it throws QueryRejected. Each readMany, and each bulk write's pre-read, gets its own budget; keyed operations spend none.

The caller gets 422 "The data source rejected the query", with the connector's message nested inside: "Reading customers would fetch more than 5000 Stripe objects. Narrow the filter, request a smaller page, or raise the connector's maxItemsPerQuery."

The budget bounds one read, not a caller's whole query. A to-many include can send one read per parent item, up to 16 at once, each with its own budget. See Handle Requests You Didn't Declare for every shape Engine sends on its own.

Done when each read matches what Stripe returns for the same request. The names below assume the workspace's default camelCase naming; see API Names.

Read one product with its prices across the relation. The SDK tab uses a client generated after the data source exists; see SDK Installation:

import { createClient } from './generated/monospace';

const client = createClient({
  url: 'https://example.monospace.io',
  workspace: 'blog',
  apiKey: 'YOUR_API_KEY',
});

const product = await client.products.readOne({
  key: 'prod_YOUR_PRODUCT',
  fields: ['id', 'name', 'active'],
  include: {
    prices: { fields: ['id', 'unitAmount', 'currency', 'recurringInterval'] },
  },
});

The product arrives with its prices nested under prices.data. unitAmount is an int64 field, so it arrives as a string:

response.json
{
  "data": {
    "id": "prod_msfx_starter",
    "name": "Fixture Starter",
    "active": true,
    "prices": {
      "data": [
        {
          "id": "price_1UIxWu9H5XlP98EJrPjZftI9",
          "unitAmount": "900",
          "currency": "eur",
          "recurringInterval": "month"
        }
      ]
    }
  }
}

Also check that a limit above 100 returns Stripe's first page plus the next, in order. A created _gte filter one microsecond after a customer's created excludes that customer.

6. Implement Keyed Operations and Writes

readOne, updateOne and deleteOne address one item by its key, plus an optional guard filter. findByKey retrieves the item by id, then checks the guard in memory:

src/data-connectors/stripe/read.ts
/**
 * Retrieves the keyed item and checks the guard on it in memory. A missing object, a deleted one
 * and a guard miss all answer `undefined`: a miss, so a keyed write changes nothing.
 */
export async function findByKey(context: QueryContext, resource: ResourceSpec, query: KeyedOperation): Promise<Item | undefined> {
    const id = query.key.id;

    if (Object.keys(query.key).length !== 1 || id === undefined) {
        throw new Error(`\`${resource.name}\` keys are a single \`id\`.`);
    }

    // The guard is checked in memory after the retrieve. Refuse one that isn't declared, or that the
    // check can't evaluate, first.
    requireDeclared(resource, query.filter);
    requireEvaluable(resource, query.filter);

    // Only a string can be a Stripe id. The client also skips ids Stripe can't have.
    if (typeof id !== 'string') return undefined;

    const object = await context.client.retrieve(resource.path, id);
    if (object === undefined) return undefined;

    const item = toItem(resource, object);
    return matches(resource, query.filter, item) ? item : undefined;
}
  • A miss is an answer, not an error. An unknown id, a deleted customer, and a guard miss all answer undefined. The client's retrieve reads both Stripe's resource_missing and a deleted: true tombstone as missing.
  • The answer is { record }. readOne answers { record: null } on a miss, and { record: … } with the selected fields otherwise. Engine answers a keyed miss with 404. See Keyed Operations.
  • The guard is checked before any write. updateOne and deleteOne write only an item findByKey returned, so a guard miss writes nothing.

Another client can still delete the object between the retrieve and the write. Stripe then answers resource_missing, which the client throws as StripeObjectMissing, and the keyed write answers that as a miss too:

src/data-connectors/stripe/write.ts
export async function updateOne(context: QueryContext, resource: ResourceSpec, query: UpdateOneOperationSerialized): Promise<QueryResultFor<'updateOne'>> {
    requireWrite(context, resource, resource.canUpdate, 'update');
    const form = encodeForm(resource, query.data, 'update');
    const target = await findByKey(context, resource, query);
    if (target === undefined) return { record: null };

    try {
        const object = await context.client.update(resource.path, target.id, form);
        return { record: project(toItem(resource, object), query.select) };
    }
    catch (error) {
        // Deleted since `findByKey` retrieved it: a miss like any other.
        if (error instanceof StripeObjectMissing) return { record: null };
        throw error;
    }
}

deleteOne works the same way. It answers the item findByKey read, because Stripe answers a delete with a tombstone.

Write One Object per Request

Stripe writes one object per request and returns the written object. create checks every item before its first request, then creates them in order. A failure partway leaves the earlier items created, so the message says how far it got:

src/data-connectors/stripe/write.ts
export async function create(context: QueryContext, resource: ResourceSpec, query: CreateOperationSerialized): Promise<QueryResultFor<'create'>> {
    requireWrite(context, resource, resource.canCreate, 'create');
    // Check every item before creating any.
    const forms = query.data.map((data) => encodeForm(resource, data, 'create'));
    const records: MonospaceObject[] = [];

    // One at a time, so a failure leaves the items before it created, in order.
    for (const form of forms) {
        try {
            const object = await context.client.create(resource.path, form);
            records.push(project(toItem(resource, object), query.select));
        }
        catch (error) {
            throw withProgress(error, `Created ${records.length} of ${forms.length} ${resource.name}`, forms.length);
        }
    }

    return { records };
}
  • Writes by filter read their targets first. updateMany and deleteMany read every match, then write each one, with at most four requests in flight. That bounds concurrency, not the rate, so Stripe can still answer 429.
  • Writes aren't atomic. When a write of several items fails, withProgress prefixes the message with the count of acknowledged writes and keeps the error's class. A request that timed out can still have been applied, so the real count can be higher.
  • deleteMany answers the items it read before deleting, because Stripe answers each delete with a tombstone.

Deleting four products by filter, where one still has prices, deletes the other three and reports the failure:

response.json
{
  "message": "Query failed",
  "source": {
    "message": "Foreign key constraint failed",
    "source": {
      "message": "the extension returned an error: Deleted 3 of 4 products before this error: This product cannot be deleted because it has one or more user-created prices."
    }
  }
}

Every write passes the same gate before its first request. With enableWrites: false, a write is a QueryRejected, because it can still arrive while Engine holds a schema introspected before the setting changed. A write or field the collection doesn't declare is a plain Error.

Creating a price sets its product through the relation. Engine doesn't accept the foreign-key field productId in create input, so connect the existing product with _connect. The SDK tab reuses the client from step 5:

const prices = await client.prices.createOne({
  data: {
    currency: 'usd',
    unitAmount: 1500,
    recurringInterval: 'month',
    product: { _connect: { key: { id: 'prod_YOUR_PRODUCT' } } },
  },
  fields: ['id', 'productId', 'unitAmount', 'currency'],
});

Engine doesn't check that the product exists. Stripe answers a missing product with resource_missing naming product, and the caller gets 422 "Foreign key constraint failed". Stripe can't delete prices: archive a price by updating active to false.

Declare What Writes Return

Every write handler answers the items it wrote, with exactly the fields the call's select asks for. The extension config tells Engine so in engine.capabilities.query, one value per kind of write:

CapabilityPromiseHow the Stripe connector keeps it
insertReturningcreate answers the created itemsStripe answers each create with the new object
updateReturningupdateMany and updateOne answer the updated itemsStripe answers each update with the updated object
deleteReturningdeleteMany and deleteOne answer the deleted itemsThe handlers answer the item they read before deleting it

A write that fails partway throws, and never answers with a partial list, so the promise holds for every answer.

Without the capabilities, Engine gets the written items from extra calls instead. A keyed update then costs five Stripe requests instead of two, and another writer can change an item between the calls. Each missing capability also adds declaration rules, which Engine checks when you create the data source. See Declare Query Capabilities.

With automatic reloading on, a build whose capabilities differ from the ones a data source was loaded with deactivates that data source. Every request that reaches its connector answers 503 until a restart or a workspace rebuild. See Capabilities.

Done when:

  • a customer create, update and delete each return the selected fields, and a read after the delete answers 404;
  • an update with a guard that misses answers 404, and a read shows nothing changed;
  • deleting a product that has a price fails with 422 "Foreign key constraint failed", with no dashboard.stripe.com link in the error;
  • a data source created with "enableWrites": false refuses a create with 422 and code 4010;
  • the built dist/manifest.json lists all three capabilities under engine.capabilities.query.

7. Test the Connector

Test in rungs, from cheapest to most realistic. The project's package.json has a script for each rung except the install:

ScriptWhat it runsWhat it asserts
lintESLint with typescript-eslint's strict type-checked rulesNo lint errors
typechecktsc --noEmitNo type errors, including typos in field names and mapping keys
testVitest, against a fake Stripe, with no network110 tests: planning, the re-check, error mapping, timeouts, keyed operations, reads, and writes
buildmonospace extension buildA bundle and a manifest
seedscripts/seed.mjs against your test-mode accountCreates the fixture customers, products and prices that are missing
smokescripts/smoke.mjs, which imports the built connector and calls Stripe19 named checks, including results compared with Stripe's own answers
smoke:enginescripts/engine-smoke.sh against a running instance47 checks of status codes and response bodies through the REST API

lint, typecheck, test and build need no Stripe key. The unit tests replace fetch with a fake Stripe. So they cover what a live account can't produce on demand, such as a timeout, a 429, or a bulk write that fails partway.

The live scripts take the key only from the environment, and the project stores none:

VariableUsed byMeaning
STRIPE_API_KEY, or STRIPE_API_KEY_FILEseed, smoke, smoke:engineA test-mode key, or a file that holds only the key. The scripts refuse any other key
ENGINE_URLsmoke:engineThe instance's base URL, http://localhost:8100 by default
ENGINE_EMAIL, ENGINE_PASSWORDsmoke:engineAn admin login, monospace@example.com and monospace by default

smoke:engine also needs curl and jq. Each run leaves one archived price on prod_msfx_locked, because Stripe can't delete prices.

Then break the connector on purpose and confirm a check fails:

  • A schemaDocument that ignores enableWrites still type-checks. It fails two unit tests and the Engine smoke.
  • Keyed writes that don't catch StripeObjectMissing fail the unit test "answers record: null when the object is deleted between the retrieve and a keyed write", and the smoke.
  • An extension config without capabilities still builds, and Engine accepts the declaration. It fails the smoke's manifest check.

Done when every rung passes twice in a row against the test-mode account, and each deliberate break turns a specific check red. See Test a Data Connector for the rungs, fixtures and techniques.

Next Steps

MonospaceThe governed API layer for every app, person, and agent.

Copyright © 2026 Monospace Inc.