Quickstart

From a new application to your first record and your first webhook. Every step runs against the live API at https://api.bind.co. Questions at any step: support@bind.co.

bind.co has no sandbox or test mode, by design: the records come from real inboxes, so a copy of the API with made-up data would not tell you how your integration behaves. Once your application is APPROVED, your developers test it end to end on their own real inbox; see Test your integration.

1. Register your application

Sign up as a developer, then, in the developer portal, create an application with its name, your verified domain and the redirect URIs bind.co may send users back to. Choose the use cases it needs: they set the scopes your application may request. You get a client_id and a client_secret. The secret is shown when the application is created and when you reset it, never again; keep it on your server.

A new application is a DRAFT. Submit it for review. Approval is a manual step: a person at bind.co reviews the application, and once they approve it, it is APPROVED and can sign users in. What each status allows is in Test your integration.

While you wait, build everything that does not need a signed-in user: the redirect to the consent screen and the code exchange (steps 3 and 4), and your webhook handler with its signature check (the webhooks guide has reference code). GET /v1/oauth2/authorize/preflight works at every status, so you can check your authorize URL before approval (step 3).

2. Register your webhook endpoint

Register your endpoint before anyone signs in, so you receive the events of their first sync. Webhook endpoints are managed with your application's own credentials over HTTP Basic, not a user's token, and need an APPROVED application:

curl -u "$CLIENT_ID:$CLIENT_SECRET" -X POST https://api.bind.co/v1/webhook-endpoints \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://app.example.com/bind/webhooks",
       "event_types": ["consent.granted", "email_sync.first_completed",
                       "expenses.first_completed", "expenses.completed"]}'

The answer carries the endpoint's secret, shown once. Every delivery is signed with it in the X-Webhook-Signature header: verify the signature on the raw body before you trust the event. POST /v1/webhook-endpoints/{public_id}/test sends a signed test delivery right away so you can check your handler. The webhooks guide has the signature scheme, retries and every event type.

Testing locally: the URL must be HTTPS on a public host; bind.co refuses private and loopback addresses. Expose your local handler through a tunnel that gives it a public HTTPS URL, and register that URL.

Authorization uses OAuth 2.0 with PKCE, and PKCE is required: method S256 only. For each sign-in, make a random code_verifier (43 to 128 characters), keep it, and send its challenge:

import base64, hashlib, secrets

code_verifier = secrets.token_urlsafe(64)
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()

Then send the user's browser to the authorize endpoint. The OAuth endpoints live at the API root, with no /v1:

https://api.bind.co/oauth2/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https://app.example.com/bind/callback
  &scope=profile:read%20operations:expenses:read
  &state=RANDOM_STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256

scope is a space-separated list; each scope must be registered for your application. Ask for profile:read every time: it is the scope that tells you who the user is. Each data area has a :read scope and a :write scope that includes it. The authentication guide lists the scopes each route needs.

The user signs in with Google or Microsoft and approves your scopes on bind.co's consent screen. bind.co then redirects to your redirect_uri with ?code=...&state=.... Check that state is the value you sent. If the user declines, you get ?error=access_denied&error_description=...&state=... instead.

To check an authorize URL before sending anyone to it, call GET /v1/oauth2/authorize/preflight with the same parameters and your application's credentials over HTTP Basic. It answers 200 with ok, each check in the order the real flow runs them, and the first one that fails.

4. Exchange the code for tokens

The code is valid for 10 minutes and works once. Exchange it, with the code_verifier, from your server:

curl -u "$CLIENT_ID:$CLIENT_SECRET" https://api.bind.co/oauth2/token \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri=https://app.example.com/bind/callback \
  -d code_verifier="$CODE_VERIFIER"
{
  "access_token": "q8Jf0...",
  "refresh_token": "Zt3k9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "profile:read operations:expenses:read"
}

The access token lasts an hour. Before it expires, get a new pair with the refresh token; the authentication guide covers refresh, rotation and revocation.

5. Wait for the first sync

A new user's records are not there the moment they consent: bind.co first reads their inbox. Your endpoint receives email_sync.first_completed when the most recent mail is stored, then <type>.first_completed (here expenses.first_completed) when the first records of that operation type are ready. Later batches arrive as <type>.completed.

"First" belongs to the user, not to your application: someone who already uses bind.co through another application had their first sync before you connected. Treat consent.granted followed by a read as your own "this user is ready" signal, and subscribe to <type>.completed as well as <type>.first_completed.

6. Make your first call

Send the access token as a bearer token. It names the user, so no route that acts for a user takes a user_id: a user_id in the query string or the body is refused with 400 invalid_request. (The webhook delivery log, GET /v1/webhook-events, is the exception: your application filters it by user_id.)

Learn who signed in:

curl https://api.bind.co/v1/oauth2/clients/me -H "Authorization: Bearer $ACCESS_TOKEN"
{"user_id": "usr_0b6f6f5e-2a8e-4b7e-9d0c-3c1f2a4b5d6e", "email": "dana@example.com"}

Then read records. This searches the user's expenses dated October 2026, newest first:

curl https://api.bind.co/v1/operations/expenses/search \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"expense_date_from": "2026-10-01", "expense_date_to": "2026-10-31",
       "sort_by": ["-expense_date"], "page_number": 1, "page_size": 50}'

The answer is one page of records (abridged here):

{
  "total_items": 1,
  "total_pages": 1,
  "page_number": 1,
  "page_size": 50,
  "items": [
    {
      "operation_id": "op_5b1f0c7e-8a2d-4c3b-9e61-2f7d4a9c0b13",
      "operation_type": "expenses",
      "status": "active",
      "vendor_normalised": {"value": "Figma", "bind_inference_confidence": 0.97},
      "expense_date": {"value": "2026-10-03", "bind_inference_confidence": 0.99},
      "total_amount": {"value": "45.00", "bind_inference_confidence": 0.98},
      "currency_code": {"value": "USD", "bind_inference_confidence": 0.99},
      "evidence": [
        {
          "evidence_type": "email",
          "role": "primary",
          "created_at": "2026-10-03T09:14:02Z",
          "file": null,
          "email": {"email_id": "eml_HUGA0aawHmv", "email_sender": "billing@figma.com", "email_timestamp": "2026-10-03T09:12:40Z"}
        }
      ],
      "schema": {"operation_type": "expenses", "version": 3}
    }
  ]
}

Each field carries its value and a bind_inference_confidence between 0 and 1. evidence points to the emails and files the record was built from. GET /v1/operations/expenses/schema describes every field, with no token needed. Paging works the same way on every list; see Pagination and idempotency.

Next