Build an OAuth connector

Create a connector that uses host-managed OAuth instead of API-key secrets.

This guide shows the minimum moving pieces for an OAuth connector:

  • connection.oauth in backfill.config.ts
  • a setup route that validates the connected provider account
  • sync routes that read provider tokens through OAuth.accessToken()
  • local tests that seed OAuth state with connectorTestRuntime

Use Build a connector first if you have not built a connector before.

1. Choose API key or OAuth

Start with the provider’s normal customer setup path:

// API-key connector: keep credentials in connection.settingsSchema secrets.
permissions: {
  secrets: { api_key: ["read"] },
},
connection: {
  settingsSchema: {
    required: ["api_key"],
    properties: {
      api_key: { type: "string", "x-secret": true },
    },
  },
},
// OAuth connector: declare connection.oauth and leave tokens to the host.
permissions: {
  secrets: { webhook_secret: ["read"] },
},
connection: {
  oauth: {
    type: "authorization_code",
    authorizationUrlTemplate: "https://provider.example.com/oauth/authorize",
    tokenUrlTemplate: "https://provider.example.com/oauth/token",
    scopes: ["orders.read"],
    client: { credentialKey: "provider_public_app" },
  },
  settingsSchema: {
    required: [],
    properties: {},
  },
},

Do not store OAuth access tokens, refresh tokens, or client secrets in connector-owned settings. The host stores tokens and OAuth app credentials separately.

2. Add the manifest

Shopify needs a tenant-specific authorization host, so the manifest uses a dynamic URL template and hostValidation.

// backfill.config.ts
import { defineConnector } from "@backfill-io/sdk";

export default defineConnector({
  key: "shopify",
  name: "Shopify",
  provider: "shopify",
  kind: "source",
  version: "0.1.0",
  permissions: {
    http: ["https://*.myshopify.com/*"],
    entities: {
      Customer: ["write"],
      SalesReceipt: ["write"],
      RefundReceipt: ["write"],
    },
    secrets: {
      webhook_secret: ["read"],
    },
  },
  connection: {
    testConnection: { route: "/setup/test_connection", method: "POST" },
    oauth: {
      type: "authorization_code",
      authorizationUrlTemplate:
        "https://{settings.shop_domain}/admin/oauth/authorize",
      tokenUrlTemplate:
        "https://{settings.shop_domain}/admin/oauth/access_token",
      scopes: ["read_customers", "read_orders", "read_refunds"],
      accessType: "offline",
      pkce: false,
      requiredSettings: ["shop_domain"],
      client: {
        credentialKey: "shopify_public_app",
      },
      token: {
        accessTokenPath: "access_token",
        scopePath: "scope",
      },
      refresh: { enabled: false },
      postConnect: {
        route: "/setup/test_connection",
        method: "POST",
      },
      hostValidation: {
        shop_domain: {
          suffix: ".myshopify.com",
          normalize: "shopifyShopDomain",
        },
      },
    },
    settingsSchema: {
      type: "object",
      required: ["shop_domain"],
      properties: {
        shop_domain: {
          type: "string",
          title: "Shop domain",
          "x-placeholder": "acme.myshopify.com",
        },
        webhook_secret: {
          type: "string",
          title: "Webhook signing secret",
          "x-secret": true,
        },
      },
    },
  },
});

The host expands {settings.shop_domain} after validating and normalizing it. If the setting appears in the URL host, the manifest must include a hostValidation rule for that setting.

3. Validate the connection

postConnect should call a setup route that proves the token works and returns provider account metadata.

// src/api/setup/test_connection.ts
import { OAuth, api, Http, Settings } from "@backfill-io/sdk";

export const config = { auth: "api_key" };

export const POST = api(async () => {
  const shopDomain = Settings.get("shop_domain");

  let accessToken: string;

  try {
    accessToken = OAuth.accessToken({ minTtlSeconds: 600 });
  } catch (error) {
    if (
      error &&
      typeof error === "object" &&
      (error as { code?: string }).code === "reauth_required"
    ) {
      return api.badRequest("Shopify OAuth connection is required");
    }

    throw error;
  }

  const response = Http.post(
    `https://${shopDomain}/admin/api/2026-04/graphql.json`,
    { query: "{ shop { id name myshopifyDomain } }" },
    {
      headers: {
        "X-Shopify-Access-Token": accessToken,
      },
    },
  );

  if (!response.ok) {
    return api.badRequest("Shopify rejected the OAuth token", {
      status: response.status,
    });
  }

  const shop = response.body.data.shop;

  return api.json({
    ok: true,
    providerAccountId: shop.id,
    providerAccountName: shop.name,
    providerAccountDomain: shop.myshopifyDomain,
  });
});

4. Use OAuth in sync routes

Keep token lookup in a small provider helper so every route handles reauth the same way.

// src/lib/provider.ts
import { OAuth, api } from "@backfill-io/sdk";

export function providerAccessToken() {
  try {
    return OAuth.accessToken({ minTtlSeconds: 600 });
  } catch (error) {
    if (
      error &&
      typeof error === "object" &&
      (error as { code?: string }).code === "reauth_required"
    ) {
      return null;
    }

    throw error;
  }
}

export function missingProviderAccessResponse() {
  return api.error("OAuth connection is not authorized", { status: 401 });
}
// src/api/sync/orders.ts
import { api, ingest } from "@backfill-io/sdk";
import {
  missingProviderAccessResponse,
  providerAccessToken,
} from "../../lib/provider";

export const config = { auth: "api_key" };

export const POST = api(async () => {
  const accessToken = providerAccessToken();

  if (!accessToken) {
    return missingProviderAccessResponse();
  }

  // Fetch one page, transform records, and emit canonical payloads.
  const payloads = [
    {
      receipt_id: "provider-order-123",
      customer_id: "provider-customer-123",
      total: "42.00",
      currency: "USD",
    },
  ];

  const result = ingest.batch("sales_receipt", payloads, {
    stream: "orders",
    continueOnError: true,
    idempotencyKey: (_payload, index) => `provider:order:${index}`,
  });

  return api.json(result);
});

For fixed-host providers that accept bearer tokens, the route can use the auth.oauth HTTP option:

const response = Http.get("https://api.example.com/v1/orders", {
  auth: { oauth: { minTtlSeconds: 300 } },
});

5. Fixed-host provider example

OAuth connectors do not need dynamic hosts when the provider has one authorization domain. A Square-style manifest can use fixed URLs and refresh enabled:

oauth: {
  type: "authorization_code",
  authorizationUrlTemplate: "https://connect.squareup.com/oauth2/authorize",
  tokenUrlTemplate: "https://connect.squareup.com/oauth2/token",
  scopes: ["PAYMENTS_READ", "ORDERS_READ", "CUSTOMERS_READ"],
  client: {
    credentialKey: "square_public_app",
  },
  refresh: { enabled: true },
  token: {
    accessTokenPath: "access_token",
    refreshTokenPath: "refresh_token",
    expiresInPath: "expires_in",
    scopePath: "scope",
  },
}

Because the URLs do not include {settings.<name>} in the host, this shape does not need hostValidation.

6. Test OAuth routes

Seed OAuth state in the test runtime. No live provider or browser flow is required for route tests.

import assert from "node:assert/strict";
import test from "node:test";
import { connectorTestRuntime } from "@backfill-io/sdk/testing";

test("setup uses OAuth token", async () => {
  const runtime = connectorTestRuntime({
    settings: { shop_domain: "demo.myshopify.com" },
    connector: { key: "shopify", provider: "shopify" },
    oauth: {
      accessToken: "test_access_token",
      scopes: ["read_orders"],
      requiredScopes: ["read_orders"],
      provider: "shopify",
    },
  });

  try {
    runtime.install();
    const { POST } = await import("../src/api/setup/test_connection");

    runtime.http.postOnce({
      status: 200,
      body: {
        data: {
          shop: {
            id: "gid://shopify/Shop/1",
            name: "Demo Shop",
            myshopifyDomain: "demo.myshopify.com",
          },
        },
      },
    });

    const response = await runtime.call(POST);

    assert.equal(response.status, 200);
    assert.equal(
      runtime.http.calls[0].opts?.headers?.["X-Shopify-Access-Token"],
      "test_access_token",
    );
  } finally {
    runtime.restore();
  }
});

Add failure tests for missing tokens and missing scopes:

const runtime = connectorTestRuntime({
  oauth: {
    accessToken: null,
    scopes: [],
    requiredScopes: ["read_orders"],
  },
});

Release checklist

Before shipping an OAuth connector:

  • the manifest validates with connection.oauth
  • required settings are present before OAuth start
  • dynamic URL hosts have hostValidation
  • the Backfill host has a company OAuth app credential setup path, and the credential exists for client.credentialKey
  • postConnect verifies provider account identity
  • route tests cover connected, missing-token, missing-scope, provider-auth-failure, and refresh-failure cases
  • logs and errors do not include access tokens, refresh tokens, or client secrets