App SDK Reference (@brainerce/app-sdk)

Manifest builder, connector / payment / shipping contracts, scopes, events and webhook verification for marketplace apps built on @brainerce/app-sdk.

@brainerce/app-sdk is not published, and the app platform is not open yet. The install command below fails with a 404 from the npm registry, and Brainerce does not currently accept third-party apps: the option is hidden in the product. This page documents the intended shape of the app SDK rather than a package you can install today. If you are building a storefront, the package you want is brainerce, which is published and supported. See the note under the install command.

Complete API reference for @brainerce/app-sdk, the SDK for building marketplace apps (connectors, payment providers, shipping carriers) that plug into Brainerce.

npm install @brainerce/app-sdk

This is not the storefront SDK. If you are building a store with a catalog, cart, checkout, payments and orders, you want the separate brainerce package (npm install brainerce, class BrainerceClient). Its guided reference is the Core Integration Guide and its full method reference ships as the package README on npm. Nothing on this page (ManifestBuilder, BrainerceAppClient, the connector contracts) exists in that package.


ManifestBuilder

Fluent builder for constructing app manifests. Validates on build().

import { ManifestBuilder } from '@brainerce/app-sdk';

const manifest = new ManifestBuilder()
  .name('My App')
  .version('1.0.0')
  .connectorIntegration('https://my-app.com', {
    pushProduct: '/sync/push-product',
    pullOrders: '/sync/pull-orders',
    connectionStatus: '/sync/connection-status',
    reconcile: '/sync/reconcile',
  })
  .hooks({
    onInstall: '/hooks/on-install',
    onUninstall: '/hooks/on-uninstall',
    onConfigUpdate: '/hooks/on-config-update',
  })
  .health('/health')
  .eventSubscriptions(['product.created', 'order.created'])
  .build(); // throws ManifestValidationError if invalid

Methods

MethodParametersDescription
name(name)stringSet the app name (required)
version(version)stringSet version in semver format, e.g. "1.0.0" (required)
connectorIntegration(serviceUrl, endpoints)string, ConnectorEndpointsConfigure as connector
paymentIntegration(serviceUrl, endpoints)string, PaymentEndpointsConfigure as payment provider
shippingIntegration(serviceUrl, endpoints)string, ShippingEndpointsConfigure as shipping provider
customIntegration(serviceUrl, contract?)string, Record<string, unknown>?Configure as custom integration
paymentOAuth(oauth)OAuthConfigAdd OAuth to the payment contract. Call after paymentIntegration(), since it is a no-op if no payment contract exists yet.
clientSdk(config)PaymentClientSdkDeclare how the storefront renders your payment UI (renderType + script/global/container). Without it the checkout has nothing to mount. displayModes lists every mode you can serve; a storefront's preferredRenderType is honoured only from that list, and the core overwrites any per-intent renderType you return outside it.
supportedCountries(countries)string[]ISO 3166-1 alpha-2 codes where the app is available (empty = everywhere)
excludedCountries(countries)string[]Codes where the app is NOT available. Takes precedence over supportedCountries
connectionFlow(flow)ConnectionFlowWhich connect UI the dashboard renders for this payment app
hooks(hooks)ManifestHooksSet lifecycle hooks
health(path)stringSet health check endpoint path
eventSubscriptions(events)string[]Subscribe to domain events
build()noneValidate and return AppManifest. Throws ManifestValidationError on failure.
toJSON()noneReturn current state without validation

validateManifest(manifest)

Standalone validation function:

import { validateManifest } from '@brainerce/app-sdk';

const result = validateManifest(myManifest);
if (!result.valid) {
  console.error(result.errors);
  // [{ field: 'name', message: 'name is required and must be a non-empty string' }]
}

BrainerceAppClient

HTTP client for your app to communicate with the Brainerce API. Uses the installation token received during app installation.

import { BrainerceAppClient } from '@brainerce/app-sdk';

const client = new BrainerceAppClient({
  token: 'app_inst_abc123', // Required: installation token
  installationId: 'inst_01HXYZ', // Required: must match the token's installation
  baseUrl: 'https://api.brainerce.com', // Optional (this is the default)
  timeout: 30000, // Optional: request timeout in ms (default: 30000)
});

installationId is required because every route the client calls is scoped under /api/v1/installations/:installationId/..., and the backend rejects a request whose route installationId doesn't match the token's own installation.

There is currently no app-facing endpoint to read or write installation config, or to report health status — BrainerceAppClient only covers secrets and resource mappings (below). Installation config is merchant-owned and set from the dashboard's settings form; your app receives it as config on every BaseAppRequest instead of fetching it itself.

Installation Secrets

// Write secrets (write-only — you cannot read secrets back). The keys you send
// are merged over the stored ones: keys not included in the call are preserved,
// so sending only a refreshed accessToken keeps the stored refreshToken.
await client.updateSecrets({ apiKey: 'new-key-123' });

// A key sent as null is deleted.
await client.updateSecrets({ oldApiKey: null });

The merged secrets are capped at 64 KB. If two writes to the same installation race, one answers 409; retry it.

Resource Mappings

Track which Brainerce resources map to which external platform resources:

// Create or update a mapping (idempotent; recommended for sync loops)
await client.upsertResourceMapping({
  resourceType: 'product',
  internalId: 'brainerce-product-id',
  externalId: '12345',
  externalUrl: 'https://platform.com/products/12345',
  platformCode: 'tiktok', // Required: matches an installed app's `App.platformCode`
  syncStatus: 'synced', // 'synced' | 'pending' | 'error' | 'stale'
});

// List mappings for a resource type (paginated)
const { data, meta } = await client.getResourceMappings({ resourceType: 'product' });
// data: Array<{ id, storeId, installationId, resourceType, internalId, externalId,
//               externalUrl, platformCode, syncStatus, lastSyncedAt, lastSyncError,
//               metadata, createdAt, updatedAt }>
// meta: { page, limit, total, totalPages }

syncStatus values:

ValueMeaning
syncedThe mapping is up to date with the external platform.
pendingThe mapping was just created/queued and hasn't synced yet.
errorThe last sync attempt failed — see lastSyncError.
staleThe mapping synced before, but the underlying resource has since changed and is due for a re-sync.

Generic Request

For any custom API call:

const result = await client.request<MyType>(
  'GET',
  `/api/v1/installations/${installationId}/mappings`
);

Contract Types

All request/response interfaces for app-to-core communication. Every request extends BaseAppRequest.

BaseAppRequest

interface BaseAppRequest {
  storeId: string;
  installationId: string;
  config: Record<string, unknown>;
  secrets: Record<string, unknown>;
}

Connector Contracts

PushProductRequest / PushProductResponse

interface PushProductRequest extends BaseAppRequest {
  productId: string;
  productData: Record<string, unknown>; // Brainerce product format
  externalId?: string; // If updating existing
}

interface PushProductResponse {
  externalId: string;
  externalUrl?: string;
}

PullOrdersRequest / PullOrdersResponse

interface PullOrdersRequest extends BaseAppRequest {
  since?: string; // ISO 8601 date — only fetch orders after this
  page?: number;
  limit?: number;
}

interface PullOrdersResponse {
  orders: Record<string, unknown>[];
  hasMore?: boolean;
  nextPage?: number;
}

ConnectionStatusRequest / ConnectionStatusResponse

type ConnectionStatusRequest = BaseAppRequest;

interface ConnectionStatusResponse {
  connected: boolean;
  scopes?: string[];
  hasRequiredScopes?: boolean;
  details?: Record<string, unknown>;
  error?: string;
}

ReconcileRequest / ReconcileResponse

interface ReconcileRequest extends BaseAppRequest {
  resourceType: string; // "product", "order", etc.
  since?: string;
}

interface ReconcileResponse {
  reconciled: number;
  created: number;
  updated: number;
  deleted: number;
  errors?: string[];
}

Payment Contracts

CreatePaymentIntentRequest / PaymentIntentResponse

interface CreatePaymentIntentRequest extends BaseAppRequest {
  amount: number;
  currency: string;
  returnUrl?: string;
  metadata?: Record<string, unknown>;
  customerId?: string;
  description?: string;
}

interface PaymentIntentResponse {
  id: string;
  /**
   * The provider's payment IDENTIFIER. Put a URL here only if it genuinely is
   * one; storefronts read `clientSdk.renderArg` first for anything they have
   * to open or render.
   */
  clientSecret: string;
  status:
    | 'requires_payment_method'
    | 'requires_confirmation'
    | 'requires_action'
    | 'processing'
    | 'succeeded'
    | 'canceled';
  amount: number;
  currency: string;
  metadata?: Record<string, unknown>;
  /** Runtime clientSdk overrides, merged with your manifest config by core. */
  clientSdk?: {
    initConfig?: Record<string, unknown>;
    /**
     * What the storefront hands to the render step: the iframe `src`, the
     * redirect target, or the argument passed to `renderMethod`. Overrides
     * `clientSecret`. If your provider returns a hosted payment page, set it
     * here.
     */
    renderArg?: string;
  };
}

renderArg is what every storefront reads first when it needs a URL, with clientSecret as the fallback. A hosted page or iframe URL left only in clientSecret appears to work and breaks as soon as clientSecret carries a real identifier instead. Full reasoning in renderArg vs clientSecret.

ConfirmPaymentRequest

interface ConfirmPaymentRequest extends BaseAppRequest {
  intentId: string;
  paymentMethodId?: string;
  metadata?: Record<string, unknown>;
}

intentId is the id your createPaymentIntent returned, bound to the checkout by the platform. paymentMethodId is the storefront's word: the platform JSON-stringifies whatever the storefront posted to its public confirm route and forwards it verbatim, so treat it as untrusted input. metadata is the platform's word: the metadata object your own createPaymentIntent response carried, persisted and echoed back server-to-server. A credential you minted at intent time and need to verify the payment with your provider (for example a process token you later pass to the provider's payment-info lookup) belongs in metadata, never in the storefront payload. Answer succeeded only after your provider confirms the payment; every other outcome must be a non-succeeding status such as processing.

RefundRequest / RefundResponse

interface RefundRequest extends BaseAppRequest {
  intentId: string;
  amount?: number; // Partial refund amount (omit for full refund)
  reason?: string;
  metadata?: Record<string, unknown>;
}

interface RefundResponse {
  id: string;
  paymentId: string;
  amount: number;
  currency: string;
  status: 'pending' | 'succeeded' | 'failed' | 'canceled';
}

OAuth Contracts

OAuthStartRequest / OAuthStartResponse

interface OAuthStartRequest extends BaseAppRequest {
  callbackUrl: string; // URL to redirect back to after authorization
}

interface OAuthStartResponse {
  redirectUrl: string; // Full URL to redirect the user to
}

OAuthCallbackRequest / OAuthCallbackResponse

interface OAuthCallbackRequest extends BaseAppRequest {
  originalPayload: Record<string, unknown>; // Query params/body from the OAuth redirect
  originalHeaders: Record<string, string>;
}

interface OAuthCallbackResponse {
  success: boolean;
  secrets?: Record<string, unknown>; // Credentials to store (encrypted automatically)
  config?: Record<string, unknown>; // Config updates
  error?: string;
}

Hook Contracts

type HookRequest = BaseAppRequest;

interface HookResponse {
  success: boolean;
  message?: string;
}

Inbound Webhook Contract

For processing webhooks from the external platform, forwarded through Brainerce:

interface InboundWebhookRequest extends BaseAppRequest {
  originalPayload: unknown;
  originalHeaders: Record<string, string>;
}

interface InboundWebhookResponse {
  received: boolean;
  processed?: boolean;
  data?: Record<string, unknown>;
}

Additional contract types

These types are exported by @brainerce/app-sdk and complete the contracts above.

// Itemized invoice lines the core sets on CreatePaymentIntentRequest.lineItems.
// Forward them to your invoicing endpoint as they are: the core guarantees that
// price * quantity across all lines equals `amount` (major units).
interface PaymentLineItem {
  name: string;
  quantity: number;
  price: number; // unit price, same currency as the payment, major units
  sku?: string;
}

// CreatePaymentIntentRequest also carries (all optional):
//   lineItems?: PaymentLineItem[];
//   metadata?: Record<string, unknown>; // core sets checkoutId, storeId, fullName, phone, email, successUrl, cancelUrl, notifyUrl
//   returnUrl?: string; customerId?: string; description?: string;

// RefundRequest also carries providerData, the original payment's Payment.providerData
// verbatim. Read provider lookup keys (for example a document number needed to cancel an
// invoice) from it, not from metadata.
//   providerData?: Record<string, unknown>;

// RefundResponse also carries the credit note a provider issued with the refund. Report
// only what the provider returned: the number is a legal document reference.
//   creditNoteNumber?: string;
//   creditNoteUrl?: string;

// Connector sync contract: inventory, coupons and deletes.
interface PushInventoryRequest extends BaseAppRequest {
  productId: string;
  variantId?: string;
  stock: number;
  trackingMode: string;
  externalId?: string;
}
interface PushInventoryResponse {
  success: boolean;
  externalId?: string;
}

interface PushCouponRequest extends BaseAppRequest {
  couponId: string;
  couponData: Record<string, unknown>;
  externalId?: string;
}
interface PushCouponResponse {
  externalId: string;
  externalUrl?: string;
}

interface DeleteResourceRequest extends BaseAppRequest {
  resourceType: string;
  externalId: string;
  internalId?: string;
}
interface DeleteResourceResponse {
  success: boolean;
  error?: string;
}

Payment webhook events

A payment app can answer an inbound webhook with a typed event. When event is present the platform queues it for order creation and status updates.

type PaymentWebhookEventType =
  | 'payment.succeeded'
  | 'payment.failed'
  | 'payment.refunded'
  | 'payment.partially_refunded'
  | 'payment.disputed'
  | 'payment.unknown';

interface PaymentMethodDetails {
  brand?: string;
  last4?: string;
  expMonth?: number;
  expYear?: number;
  [key: string]: unknown;
}

interface NormalizedPaymentEvent {
  type: PaymentWebhookEventType;
  // At least one lookup key, so the core can find the checkout or payment.
  checkoutId?: string;
  paymentIntentId?: string;
  paymentExternalId?: string;
  // payment.succeeded: used for order creation.
  payment?: {
    externalId: string;
    amount: number; // human-readable units, 50.00 and not 5000 cents
    currency: string; // ISO 4217
    paymentMethod: string; // normalized: "credit_card", "apple_pay", "google_pay", "paypal", ...
    paymentMethodDetails?: PaymentMethodDetails;
  };
  // payment.refunded and payment.partially_refunded.
  refund?: { amount: number; externalId?: string; reason?: string };
  // payment.failed.
  error?: { code?: string; message?: string };
  // Stored on Payment.providerData and never parsed by the core.
  providerData?: Record<string, unknown>;
}

interface NormalizedPaymentWebhookResponse extends InboundWebhookResponse {
  received: boolean;
  processed: boolean;
  event?: NormalizedPaymentEvent; // set when the webhook parsed into a payment event
}

// Invoice data a webhook can return in data.invoice. Any webhook may return it, not only
// an invoice-notify one; it is stored on Payment.providerData.
interface WebhookInvoicePayload {
  processId: string; // matches Payment.checkoutId or a provider process id
  invoiceNumber?: string;
  invoiceUrl: string;
}

InboundWebhookResponse.data may also carry configUpdate (merged into the installation's config) and secretsUpdate (encrypted and merged into its secrets). A key is stored only when it is declared in the app's config schema or secrets schema (or is one of the platform's own metadata keys) and is not marked read-only; one refused key drops the whole configUpdate (and, separately, the whole secretsUpdate), silently, while the webhook still answers processed: true.


Manifest Types

type IntegrationType = 'payment' | 'connector' | 'shipping' | 'custom';

interface AppManifest {
  name: string;
  version: string; // Semver: "1.0.0"
  integration: IntegrationConfig;
  clientSdk?: PaymentClientSdk; // how the storefront renders your payment UI, see below
  hooks?: ManifestHooks;
  health?: string; // e.g. "/health"
  eventSubscriptions?: string[]; // e.g. ["product.created", "order.updated"]
  supportedCountries?: string[]; // ISO 3166-1 alpha-2; empty = all countries
  excludedCountries?: string[]; // takes precedence over supportedCountries
  connectionFlow?: ConnectionFlow; // 'oauth' | 'api-key' | 'registration': which connect UI to render
  capabilities?: AppCapabilities; // explicit flags, overrides the endpoint-presence inference
  // Allowlist of extra endpoint paths callable through the generic
  // POST /api/apps/:installationId/call/:path proxy. Paths are relative to serviceUrl and
  // have no leading slash ("registration/submit"). Any path not listed is rejected.
  publicEndpoints?: string[];
}

// Declarative capability flags. A capability defaults to true when its endpoint exists
// (refund endpoint present means refund: true); an explicit false force-disables it.
interface AppCapabilities {
  refund?: boolean;
  openTerminal?: boolean;
  installments?: boolean; // the app can offer credit-card installments at checkout
}

type ConnectionFlow = 'oauth' | 'api-key' | 'registration';

interface PaymentClientSdk {
  renderType: 'sdk-widget' | 'iframe' | 'redirect' | 'sandbox' | 'embedded-fields'; // the default mode
  displayModes?: Array<'sdk-widget' | 'iframe' | 'redirect' | 'sandbox' | 'embedded-fields'>;
  scriptUrl?: string; // main SDK script to load
  globalName?: string; // global set by that script, the name your provider's script defines
  initMethod?: string; // default 'init'
  renderMethod?: string; // called to render the widget
  containerId?: string; // DOM element ID of the widget container
  initConfig?: Record<string, unknown>; // passed to the init method
  renderArg?: string; // argument for the render method, overrides clientSecret when set
  additionalScripts?: Array<{ url: string; optional?: boolean }>; // for example the Apple Pay SDK
  bodyStyles?: string; // CSS injected into <head> while the SDK is active
  embeddedFieldsUrl?: string; // embedded-fields: base URL the sensitive-field iframes load from
  embeddedFieldsSessionId?: string; // embedded-fields: provider session id for the iframe handshake
  embeddedFieldsProvider?: 'cardcom'; // embedded-fields: reserved. 'cardcom' is the only value the contract declares, and no payment provider sends it today
}

interface IntegrationConfig {
  type: IntegrationType;
  serviceUrl: string; // Where your service runs
  paymentContract?: { endpoints: PaymentEndpoints; oauth?: OAuthConfig };
  connectorContract?: { endpoints: ConnectorEndpoints; oauth?: OAuthConfig };
  shippingContract?: {
    endpoints: ShippingEndpoints;
    serviceType?: 'parcel' | 'on_demand'; // default 'parcel'; 'on_demand' = a live courier (no label, a time-limited quote)
    capabilities?: ShippingCapabilities; // what makes this carrier different: see "Shipping contract" below
    oauth?: OAuthConfig; // omit for an API-key carrier with no connect flow
  };
  receiptContract?: { endpoints: ReceiptEndpoints }; // optional, manifest JSON only for now: see "Receipt contract" below
  customContract?: Record<string, unknown>;
}

Endpoint Types

interface ConnectorEndpoints {
  pushProduct?: string;
  pushInventory?: string;
  pullProducts?: string;
  pullOrders?: string;
  connectionStatus?: string;
  reconcile?: string;
  deleteResource?: string;
  pushCategory?: string;
  pullCategories?: string;
  pushTag?: string;
  pullTags?: string;
  pushBrand?: string;
  pullBrands?: string;
  pushAttribute?: string;
  pullAttributes?: string;
  pushOrderNotes?: string;
  pushOrderStatus?: string;
  pushShipping?: string;
  getRefunds?: string;
  createRefund?: string;
  updateOrderFields?: string;
  pushCoupon?: string;
}

interface PaymentEndpoints {
  createPaymentIntent: string; // Required
  confirmPayment: string; // Required
  refund: string; // Required
  connectionStatus?: string;
}

interface ShippingEndpoints {
  getRates?: string; // rate shopping at checkout and in the dashboard label flow
  createShipment?: string; // buys a label for a previously quoted rate
  trackShipment?: string; // polled for tracking (needed when trackingMode is 'poll' or 'both')
  voidShipment?: string; // optional: absent means "this app cannot cancel"
  createReturnShipment?: string; // optional, same terms
  validateCredentials?: string; // optional: check a credential before it is saved, no side effects
  getLabel?: string; // for labelDelivery: 'inline' apps
  listPickupPoints?: string; // optional: partner shops and lockers near the shipping address (see below)
}

interface ReceiptEndpoints {
  createDonationReceipt: string; // e.g. "/receipts/donation"
}

interface OAuthConfig {
  startPath: string; // e.g. "/oauth/start"
  callbackPath: string; // e.g. "/oauth/callback"
}

interface ManifestHooks {
  onInstall?: string;
  onUninstall?: string;
  onConfigUpdate?: string;
}

Receipt contract (donation receipts)

Optional, for an app that keeps an organisation's receipt books (donation receipts and their refund counterparts). An installed app whose manifest declares integration.receiptContract.endpoints.createDonationReceipt is offered to the merchant as a receipt provider in the dashboard (Donations page, Receipts tab). Declare it in the manifest JSON: the IntegrationConfig type exported by @brainerce/app-sdk does not list receiptContract yet, but the platform reads it from the stored manifest. The platform's workers then call that endpoint once per receipt, with CreateDonationReceiptRequest and expecting CreateDonationReceiptResponse (both in @brainerce/integration-shared).

Nothing else in the platform names a provider, so declaring the endpoint is all it takes to join. A published app that declares it is listed in the dashboard as one the merchant can connect, and once installed it is offered as a receipt provider. A payment gateway can declare it too. The app is chosen like this:

  • The merchant's choice wins. If the merchant picked an app on the Receipts tab, every receipt is issued by it.
  • Otherwise the app that charged the gift, when it declares the endpoint, so a gateway that also keeps the books is used without setup (and a gift it already wrote a document for is held for review instead of getting a second one). Failing that, the store's first installed app that declares it.
  • A gift your app charged and already wrote a document for is held, not doubled. Report that document from your payment webhook as providerData.providerDocument = { number, type? }. The receipt then waits for the merchant to record the existing document or issue the donation receipt anyway. (An older providerData.documentInfo shape is read the same way.)
  • A refund credit always goes to the app that issued the receipt it reverses. A credit citing another provider's document number is not a valid credit, so if that app is no longer installed the credit fails visibly instead of going elsewhere. Your endpoint receives the original document's number and type in originalDocument.
interface CreateDonationReceiptRequest {
  kind: 'RECEIPT' | 'REFUND';
  externalId: string; // the platform's receipt id: store it as the document's external id
  donorName: string;
  donorTaxId: string; // the donor's national ID or company number, required on a receipt
  donorEmail?: string;
  donorPhone?: string;
  sendByEmail: boolean; // the provider emails the document to the donor itself
  amount: number; // MAJOR units (180.00), not cents
  currency: string;
  description: string; // one line, e.g. "Donation: Building fund"
  language: 'he' | 'en';
  paidAt: string; // ISO 8601: when the gift was paid (or refunded)
  payment: { provider: string; reference?: string; sameProviderDealNumber?: number };
  originalDocument?: { number: number; type: string }; // kind REFUND: the receipt it reverses
}

interface CreateDonationReceiptResponse {
  documentNumber: number;
  documentType: string;
  documentUrl?: string | null;
}

The status code is the contract. A receipt is a legal record, so the platform never guesses:

Your answerMeaningWhat the platform does
2xxA document exists. Return its number.Marks the receipt issued.
4xxThe provider definitely created nothing.Marks it "Not issued" with your message; the merchant may try again after fixing it.
5xxYou cannot tell (timeout, ambiguous provider answer).Marks it "Check your books". It is never re-issued automatically.

Answer 4xx only when you are certain nothing was written; when in doubt, answer 5xx. A 4xx that hid a created document would put two receipts in the books for one gift. The platform also calls nothing while the app installation's config is in test mode, so a real donor never reaches a test account.

Shipping contract (carrier apps)

A carrier is an app with type: 'shipping'. The core never branches on a carrier's name: whatever makes a carrier different is declared in its manifest, and the core adapts. Every request below extends BaseAppRequest; types live in @brainerce/integration-shared (GetShippingRatesRequest, CreateShipmentRequest, ListPickupPointsRequest, ...).

EndpointRequest → response
getRates{ origin, destination, parcels[], checkoutCurrency?, customs? } → { rates: NormalizedShippingRate[] }
createShipment{ shipmentId, rateObjectId, orderId, toAddress, fromAddress, parcels[], labelFormat?, customs?, cashOnDelivery?, pickupPoint? } → { externalId, trackingNumber, carrier, labelUrl?, labelContent?, trackingUrl?, ... }
trackShipmenta shipment reference → its current tracking state
voidShipment, createReturnShipmentOptional. Declaring neither means the dashboard offers neither
validateCredentialsOptional. Check a credential before it is saved, with no side effects
getLabelOnly for labelDelivery: 'inline': { shipmentId, externalId, trackingNumber } → { labelContent: { base64, format } }
listPickupPointsOptional. { country, city?, postalCode?, latitude?, longitude?, limit } → { points: PickupPoint[] }

Capabilities. Declare behaviour differences in shippingContract.capabilities. Every field is optional, and the default is what every app did before the field existed:

FieldValues (default first)Meaning
trackingMode'push', 'poll', 'both'How the core learns a shipment moved: the carrier calls the platform, the core polls trackShipment, or both (webhook primary, polling as a safety net)
trackingCadence'standard', 'relaxed'Polling rhythm. relaxed suits a courier whose status moves a few times a day (about 70% fewer calls)
quoting'live', 'none''none' for a carrier with no rate API: never quoted at checkout, labels are bought without a rate, and the shopper pays the store's own shipping zones
labelDelivery'url', 'inline''inline' when fetching the label needs the merchant's credential: the app returns the bytes and the core stores them
billing'prepaid_wallet', 'contract'prepaid_wallet drains a balance the dashboard warns about; contract is invoiced by the carrier
requiredRecipientFieldsArray<'phone' | 'email' | 'name'>Recipient fields the carrier refuses to ship without, checked before anything is bought
originAddress'sent', 'carrier_side''carrier_side' when the carrier collects from an address held in the merchant's contract and reads none from the platform

ManifestBuilder.shippingIntegration() sets the endpoints only. Put serviceType, capabilities and oauth on manifest.integration.shippingContract yourself after build(). The @brainerce/app-sdk ShippingCapabilities type does not yet list originAddress or trackingCadence, and ShippingEndpoints does not yet list listPickupPoints: the platform reads all three from the manifest JSON.

Pickup points. An app that lists listPickupPoints lets a storefront offer carrier pickup (the shopper must be on an SDK client that declares features: ['pickup-points'], see Choose a carrier pickup point). The platform only ever sends createShipment a pickupPoint that this app listed, so an app that declares the endpoint must deliver to it. Return no secrets in a point.

A refusal is a 4xx; an unknown outcome is not. The platform decides what a failed createShipment means from your HTTP status: a 4xx other than 408 or 429 means the carrier refused and nothing exists (the order stays retryable); a 5xx, 408, 429 or a timeout means the shipment may exist, so it is not retried blindly. Answer an unconfigured request with a 4xx, never echo a credential in any response, answer { rates: [] } rather than invent a price when you have no rate API, and derive the carrier's references from shipmentId so a retry after a lost reply finds the shipment instead of buying a second one.


Scopes

type AppScope =
  | 'products:read'
  | 'products:write'
  | 'orders:read'
  | 'orders:write'
  | 'inventory:read'
  | 'inventory:write'
  | 'customers:read'
  | 'customers:write';

// Full list
import { APP_SCOPES } from '@brainerce/app-sdk';

// Human-readable descriptions
import { SCOPE_DESCRIPTIONS } from '@brainerce/app-sdk';
// { 'products:read': 'Read product data', 'products:write': 'Create and update products', ... }

Domain Events

type DomainEventType =
  | 'product.created'
  | 'product.updated'
  | 'product.deleted'
  | 'order.created'
  | 'order.updated'
  | 'inventory.changed';

interface DomainEvent {
  event: DomainEventType;
  storeId: string;
  installationId: string;
  timestamp: string; // ISO 8601
  data: Record<string, unknown>; // Event-specific payload
}

// Full list
import { DOMAIN_EVENT_TYPES } from '@brainerce/app-sdk';

Webhook Verification

If your app uses createIntegrationApp() from @brainerce/integration-shared, signature verification is automatic. Just set the environment variable:

# Ed25519 public key from the Brainerce platform (NOT sensitive)
BRAINERCE_PUBLIC_KEY=MCowBQYDK2VwAyEA...

All incoming requests are verified before reaching your handlers. No code needed.

Alternative: Manual HMAC Verification (via app-sdk)

For apps that don't use integration-shared (e.g., standalone services receiving event webhooks), you can verify manually with HMAC:

import { verifyWebhookSignature } from '@brainerce/app-sdk';

app.post('/webhooks', (req, res) => {
  const isValid = verifyWebhookSignature({
    body: req.rawBody, // Raw request body as string
    signature: req.headers['x-brainerce-signature'], // Hex-encoded HMAC
    timestamp: req.headers['x-timestamp'], // ISO 8601
    secret: savedWebhookSecret, // From your DB (received during onInstall)
    maxAgeMs: 300_000, // Optional: max age (default 5 min)
  });

  if (!isValid) {
    return res.status(401).send('Invalid signature');
  }

  // Process the webhook...
});

Important: Store the webhookSecret received during onInstall in your database, not in memory. It must survive restarts.

Signature format: HMAC-SHA256(secret, "${timestamp}.${body}") → hex-encoded. Uses timing-safe comparison and rejects expired or future timestamps.


Errors

import { AppClientError, ManifestValidationError } from '@brainerce/app-sdk';

// Thrown by BrainerceAppClient on HTTP errors
class AppClientError extends Error {
  status: number;
  data?: unknown;
}

// Thrown by ManifestBuilder.build() on validation failure
class ManifestValidationError extends Error {
  errors: Array<{ field: string; message: string }>;
}