Developer

REST API Reference

Base URL: https://api.pullpush.ai (prod) · http://localhost:4264 (dev)

Prefer MCP or the dashboard for control-plane work (create connections, mappings, preview, reconcile, triage). This HTTP reference is intentionally thin: ingest, webhooks, sandbox, storefront plugins, and automation helpers (operations / import / export / backup). Full agent surface: MCP tools and docs/mcp-tools.md.


Authentication

Send a Bearer token unless noted otherwise:

Authorization: Bearer <token>
Token Scope Used for
Tenant API key (pp_…) One tenant Ingest, webhooks setup, imports/exports, backups, sandbox claim, Woo connect
Org API key All tenants in an organization Same as tenant key where authenticateTenantRoute is used; READONLY org keys get 403 on writes
OAuth JWT (ChatGPT / web MCP) Caller's tenant via membership POST /mcp only
Platform admin (MCP_API_KEY) All tenants Trusted internal callers (admin app, MCP admin)

Some routes accept only a tenant key (exports, backups, Woo connect). Org keys and MCP_API_KEY are rejected there.

POST /api/v1/events accepts the tenant key via the x-api-key header instead of Bearer.


Discovery

Method Path Auth Purpose
GET /.well-known/mcp-server-card.json None MCP server card (tools + streamable HTTP URL)
GET /.well-known/oauth-protected-resource None OAuth protected-resource metadata (RFC 9728) for ChatGPT / remote MCP
GET /api/mcp/info Bearer (tenant, org, or admin) Tool names, descriptions, counts — no full schemas

Health

GET /health

Liveness check.

{ "ok": true, "service": "kaduno-pullpush-api" }

GET /health/ready

Postgres + Redis readiness. Returns 503 if a dependency is down.

{
  "ok": true,
  "checks": { "postgres": true, "redis": true },
  "service": "kaduno-pullpush-api"
}

Sandbox (try without signup)

POST /api/sandbox

Public. Mints a 48-hour sandbox tenant, a one-time pp_… API key, and two pre-wired echo connections whose stock snapshots deliberately disagree so a sync preview shows a real diff. Rate limit: 5 creations per IP per day (global cap on active sandboxes).

Response 201:

{
  "ok": true,
  "sandbox": true,
  "tenant": { "id": "…", "slug": "sandbox-…", "name": "…" },
  "apiKey": "pp_…",
  "expiresAt": "2026-07-27T00:00:00.000Z",
  "mcp": { "remote": { "…": "…" }, "npx": { "…": "…" } },
  "preWired": {
    "source": { "id": "…", "type": "echo", "direction": "SOURCE" },
    "destination": { "id": "…", "type": "echo", "direction": "DESTINATION" }
  },
  "nextSteps": ["…"]
}

POST /api/sandbox/claim

Convert a sandbox into a persistent free-plan tenant.

Auth: Sandbox tenant API key (Bearer)

Body: { "email": "you@example.com" }

Links an existing user as OWNER, or returns a signupUrl if the email is new.


Event ingest

POST /api/v1/tenants/:tenantId/events

Ingest an event for a tenant. Deduplicated, written to the outbox, and queued.

Auth: Tenant API key (Bearer)

Body:

{
  "type": "order.created",
  "externalId": "ORD-12345",
  "occurredAt": "2026-06-16T10:00:00Z",
  "payload": { "orderId": "12345", "total": 599.0, "currency": "NOK" }
}
Field Required Description
type Yes Event type (e.g. order.created, product.updated)
externalId Yes Unique ID from the source system
occurredAt No ISO 8601 (defaults to now)
payload No Event data (defaults to {})

Response 200: { "ok": true, "eventId": "…", "requestId": "…" }

Errors: 401 missing_api_key / invalid_tenant_or_key, 400 invalid_body

Deduplication: Same tenant + externalId + type returns the existing event without creating a duplicate.

POST /api/v1/events

Connection-targeted ingest (used by partner connectors such as K-Connect).

Auth: Tenant API key via x-api-key header

Body: Same as above, plus required connectionId.

Response 202: { "eventId": "…" }


Webhooks

POST /api/v1/webhooks/:tenantId/:connectionId

Ingress for external systems. The connector verifies the signature, parses events, and queues them.

Auth: Connector HMAC / signature headers — no Bearer token.

Common headers (connector-specific):

  • x-wc-webhook-signature (WooCommerce)
  • x-shopify-hmac-sha256 (Shopify)
  • x-magento-signature (Magento)
  • x-hub-signature-256 / x-hub-signature / x-signature / x-webhook-signature

Response 202: { "ok": true, "eventIds": ["…"], "requestId": "…" }

Replay of a recently seen payload may return 202 with { "replay": true }.

Errors: 404 connection_not_found, 400 connector_has_no_webhook, 401 invalid_signature


API key rotation

POST /api/v1/tenants/:tenantId/keys/rotate

Revokes existing tenant keys and issues a new pp_… key.

Auth: Current tenant API key

Response 200: { "ok": true, "apiKey": "pp_…", "prefix": "pp_abc12", "requestId": "…" }

Store the new key immediately — it cannot be retrieved again.


Flow preview

GET /api/v1/tenants/:tenantId/flows/:flowId/preview

Zero-write sync preview: live snapshots of source + destination in canonical space, then diff. Heavy — use on demand.

Auth: Tenant key, org key, or admin key

Response 200 (SyncPreviewResult):

{
  "summary": { "total": 5333, "equal": 103, "changed": 84, "new": 5068, "missing": 78 },
  "items": [
    {
      "key": "BC-1001",
      "source": {},
      "destination": {},
      "diff": "equal|changed|missing_in_dest|missing_in_source"
    }
  ],
  "validation": { "source": [], "destination": [] }
}

Async operations

Long-running jobs (reconcile, poll, backfill, etc.) return immediately with an operation id and optional SSE stream.

POST /api/v1/tenants/:tenantId/operations

Auth: Tenant / org / admin key (writes blocked for READONLY org keys)

Body:

{
  "type": "reconcile",
  "flowId": "…",
  "scopeToDestination": true,
  "pushDeletes": false
}
type Typical extras
reconcile flowId, scopeToDestination?, pushDeletes?
poll connectionId
deliver-batch flowId
provision connector-specific fields
migration migrationId, isFinal?
export connectionId, canonicalType, format, emailTo?
import-from-connection connection/flow fields
backfill since?, until?, limit?, …

Response 202: { "operationId": "…", "status": "running", "streamUrl": "/api/v1/tenants/…/operations/…/stream" }

GET /api/v1/tenants/:tenantId/operations/:operationId

Poll status.

GET /api/v1/tenants/:tenantId/operations/:operationId/stream

Server-Sent Events progress stream.


File imports

CSV/JSON → canonical → preview → deliver through a flow.

Method Path Purpose
POST /api/v1/tenants/:tenantId/imports?canonicalType=stocklevel Multipart upload + parse (201)
PATCH /api/v1/tenants/:tenantId/imports/:importId/mapping Set { "columnMapping": { … } }
GET /api/v1/tenants/:tenantId/imports/:importId/preview?flowId=… Diff against flow destination
POST /api/v1/tenants/:tenantId/imports/:importId/deliver Body { "flowId" } — execute
GET /api/v1/tenants/:tenantId/imports/:importId Status

Auth: Tenant / org / admin key (writes require write scope)


Exports

Method Path Purpose
POST /api/v1/tenants/:tenantId/exports Start export (202)
GET /api/v1/tenants/:tenantId/exports List (?connectionId=)
GET /api/v1/tenants/:tenantId/exports/:exportId Status
GET /api/v1/exports/:exportId/download Download file

Auth: Tenant API key only

Start body: { "connectionId", "canonicalType", "format": "csv"|"json"|"email", "emailTo?", "fieldHint?" }


Connector data backups

Tenant-key-only product surface for connection snapshots (scheduling depends on plan / deployment).

Method Path Purpose
POST /api/v1/tenants/:tenantId/backup-policies Create/update policy
GET /api/v1/tenants/:tenantId/backup-policies List policies
GET /api/v1/tenants/:tenantId/backups List snapshots
POST /api/v1/tenants/:tenantId/backup-policies/:policyId/run Manual run
POST /api/v1/tenants/:tenantId/backups/:snapshotId/restore Restore (destinationConnectionId?)
GET /api/v1/tenants/:tenantId/backups/:snapshotId/download Download (gzip JSONL)
GET /api/v1/tenants/:tenantId/backup-usage Storage quota

Policy body: { "connectionId", "canonicalTypes": ["stocklevel"], "cronExpression?", "retentionDays?", "enabled?" }


OAuth2 (dynamic connectors)

For connector definitions with auth.type: "oauth2".

POST /api/v1/oauth/authorize

Auth: Tenant / org / admin key

Body: { "tenantId", "connectorType", "connectionId?", "scopes?", "redirectUri?" }

Response 200: { "ok": true, "redirectUrl": "…", "state": "…" }

GET /api/v1/oauth/callback

Browser callback. Exchanges the code (PKCE when enabled), stores encrypted tokens, redirects to {ADMIN_URL}/connections?oauth=success&connector={type}.


Storefront plugins

POST /api/connect/woocommerce

One-click handshake used by the WordPress / WooCommerce plugin.

Auth: Tenant API key (Bearer connect token)

Body: WooCommerce credentials (store_url, consumer_key, consumer_secret, webhook_secret, …). Plugin-only fields (platform, plugin_version) are stripped.

Live-tests credentials, creates SOURCE (and DESTINATION if quota allows) connections with envelope-encrypted config.

Response 201:

{
  "ok": true,
  "connectionId": "…",
  "destinationConnectionId": "…",
  "webhook_url": "https://api.pullpush.ai/api/v1/webhooks/{tenantId}/{connectionId}",
  "webhook_topics": ["order.created", "order.updated", "product.updated"]
}

Shopify

Method Path Purpose
GET /api/shopify/install Start install (Shopify HMAC; optional ?token= tenant key)
GET /api/shopify/callback Complete OAuth, create connection, register webhooks
POST /api/shopify/gdpr Mandatory Shopify GDPR webhooks (HMAC on body)

MCP HTTP

POST /mcp

JSON-RPC 2.0 Streamable HTTP endpoint for MCP tools and widgets. See MCP tools and the auto-generated docs/mcp-tools.md.

Auth: Tenant key, org key, admin key, or OAuth JWT (ChatGPT)

Optional headers: x-mcp-scope (operator | dev), x-mcp-workflow-id.

GET /mcp

Returns 405 — clients must use POST (stateless Streamable HTTP).


Alert acknowledge links

One-click links from email notifications (unguessable ids / fingerprints — no API key):

  • GET /alerts/:id/ack
  • GET /alerts/ack?fp=…&t=…

Common patterns

Request ID

Every request gets a requestId (from X-Request-Id or auto-generated). Included in responses and logs.

Rate limiting

  • Global: 120 requests/minute per IP429
  • Plan limits apply to ingest, /api/v1/events, and MCP (see X-RateLimit-* headers)

Errors

{
  "error": "error_code",
  "details": {}
}

Some partner routes use slightly different message shapes; treat error as the stable code.


Related docs