> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Call any route from every SDK: the raw-request cookbook

> Every SDK's untyped request escape hatch side by side with cURL — one worked segment-to-campaign flow in all seven languages, envelope handling, typed 429 errors with a no-SDK retry loop, and sandbox key semantics.

# Call any route from every SDK

Every Orbit API route is reachable from every SDK, including routes the
SDK's typed modules do not wrap yet. Each client ships one untyped request
method that carries the same authentication, the same automatic
`429`/`5xx` retry with backoff, and the same typed errors as the typed
helpers. This page shows that call side by side with cURL and walks one
real flow end to end: build a segment, create a contact that matches it,
launch a campaign against it, and send.

The [Sandbox API cookbook](https://docs.orbit.devotel.io/guides/sandbox-api-samples.md) is the recipe
sheet for sandbox routes; this page is the equivalent for the escape
hatch itself, on any route.

## 1. Why the escape hatch exists

Typed coverage is core scope. The typed helpers each SDK ships wrap the
routes most integrations touch, and the SDK packages are the coverage
source of truth: this page promises nothing the packages do not ship.
Routes go live as public API endpoints first, so a route below the typed
modules is still a full citizen of the API: same `X-API-Key` auth, same
rate limits, same `{ "data", "meta" }` envelope, same error codes.

The untyped request method is how you reach those routes without
dropping to raw HTTP. Compared with a hand-rolled HTTP call it gives you:

* **The same auth** — the client attaches your `X-API-Key` and
  `User-Agent`; you never assemble the header by hand.
* **The same retry** — `429` and `5xx` responses are retried with
  exponential backoff that honours `Retry-After`, and every mutating
  request carries an `Idempotency-Key`, so a retried `POST` is not a
  double send.
* **The same typed errors** — a `429` that exhausts the retry budget
  surfaces as the SDK's rate-limit error type (and a `400`/`422` as the
  validation error type), not a raw status code you have to switch on.

Per the [SDK sample coverage
policy](https://docs.orbit.devotel.io/api-reference/sdk-language-policy.md), cURL and Node.js
(TypeScript) are the floor on every sample in these docs; Python, Go,
Ruby, and PHP are published SDKs; Java and C# are pre-publish and vendor
from source. The worked flow below carries the six published tabs on
every step, with the Java and C# forms following in their own section.

## 2. The unified call matrix

One row per language, same call. The one difference to memorise is the
path base: cURL and the Node client take the **full** path including
`/api/v1`; every other client takes paths **relative to** `/api/v1`.

| Language | The escape hatch | Path base | Status |
| - | - | - | - |
| cURL | `curl https://api.orbit.devotel.io/api/v1/…` | full URL | floor |
| Node.js | `await orbit.request('GET', '/api/v1/…')` | full path | published |
| Python | `client.request("GET", "/…", params=…, json_body=…)` | relative to `/api/v1` | published |
| Go | `client.Request(ctx, "GET", "/…", query, body, &out, idempotencyKey)` | relative to `/api/v1` | published |
| Ruby | `client.request("GET", "/…", query:, json_body:)` | relative to `/api/v1` | published |
| PHP | `$client->request('GET', '/…', $query, $jsonBody)` | relative to `/api/v1` | published |
| Java | `client.request("GET", "/…", query, jsonBody, idempotencyKey)` | relative to `/api/v1` | pre-publish |
| C# | `await client.RequestAsync("GET", "/…", query, jsonBody)` | relative to `/api/v1` | pre-publish |

> [!WARNING]
>
> Passing `/api/v1/contacts` to the Python, Go, Ruby, PHP, Java, or C#
> client produces `/api/v1/api/v1/contacts` and a `404`. Drop the prefix
> there; keep it on cURL and Node.js.

Clients for the worked flow, one per language:

```typescript Node.js theme={null}
import { Orbit } from '@devotel-orbit/node';

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY });
```

```python Python theme={null}
from orbit_sdk import OrbitClient

client = OrbitClient.from_env()  # reads ORBIT_API_KEY
```

```go Go theme={null}
client, err := orbit.NewClient(os.Getenv("ORBIT_API_KEY"))
if err != nil {
    log.Fatal(err)
}
```

```ruby Ruby theme={null}
client = OrbitSdk::Client.from_env  # reads ORBIT_API_KEY
```

```php PHP theme={null}
use Devotel\Orbit\OrbitClient;

$client = OrbitClient::fromEnv();  // reads ORBIT_API_KEY
```

```java Java — pre-publish, vendor from source theme={null}
OrbitClient client = OrbitClient.fromEnv();  // reads ORBIT_API_KEY
```

```csharp C# — pre-publish, vendor from source theme={null}
var client = OrbitClient.FromEnv(); // reads ORBIT_API_KEY
```

## 3. Worked flow: segment to campaign

The flow: create a segment from a filter, create a contact the filter
matches (segment membership is materialised from the filter, so adding a
contact to a segment means creating or updating a contact whose
attributes match), create a campaign targeted at the segment, and send
it. Four calls, shown in every language.

### Step 1 — Create the segment

`POST /contacts/segments` returns `201` with the created,
already-materialised segment.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/segments \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "High-value EU customers",
    "auto_refresh": true,
    "filters": {
      "op": "AND",
      "conditions": [
        { "field": "lifecycle_stage", "op": "equals", "value": "customer" },
        { "field": "country_code", "op": "equals", "value": "DE" }
      ]
    }
  }'
```

```typescript Node.js theme={null}
const { data: segment } = await orbit.request<{ data: { id: string } }>(
  'POST',
  '/api/v1/contacts/segments',
  {
    name: 'High-value EU customers',
    auto_refresh: true,
    filters: {
      op: 'AND',
      conditions: [
        { field: 'lifecycle_stage', op: 'equals', value: 'customer' },
        { field: 'country_code', op: 'equals', value: 'DE' },
      ],
    },
  },
);

console.log(segment.id); // seg_…
```

```python Python theme={null}
envelope = client.request(
    "POST",
    "/contacts/segments",
    json_body={
        "name": "High-value EU customers",
        "auto_refresh": True,
        "filters": {
            "op": "AND",
            "conditions": [
                {"field": "lifecycle_stage", "op": "equals", "value": "customer"},
                {"field": "country_code", "op": "equals", "value": "DE"},
            ],
        },
    },
)

segment_id = envelope["data"]["id"]
```

```go Go theme={null}
var created map[string]any
if err := client.Request(context.Background(), "POST", "/contacts/segments", nil, map[string]any{
    "name":         "High-value EU customers",
    "auto_refresh": true,
    "filters": map[string]any{
        "op": "AND",
        "conditions": []map[string]any{
            {"field": "lifecycle_stage", "op": "equals", "value": "customer"},
            {"field": "country_code", "op": "equals", "value": "DE"},
        },
    },
}, &created, ""); err != nil {
    log.Fatal(err)
}

segmentID := created["data"].(map[string]any)["id"].(string)
```

```ruby Ruby theme={null}
envelope = client.request("POST", "/contacts/segments", json_body: {
  name: "High-value EU customers",
  auto_refresh: true,
  filters: {
    op: "AND",
    conditions: [
      { field: "lifecycle_stage", op: "equals", value: "customer" },
      { field: "country_code", op: "equals", value: "DE" }
    ]
  }
})

segment_id = envelope["data"]["id"]
```

```php PHP theme={null}
$envelope = $client->request('POST', '/contacts/segments', null, [
    'name' => 'High-value EU customers',
    'auto_refresh' => true,
    'filters' => [
        'op' => 'AND',
        'conditions' => [
            ['field' => 'lifecycle_stage', 'op' => 'equals', 'value' => 'customer'],
            ['field' => 'country_code', 'op' => 'equals', 'value' => 'DE'],
        ],
    ],
]);

$segmentId = $envelope['data']['id'];
```

### Step 2 — Create a contact the segment matches

`POST /contacts` with attributes that satisfy the filter. The segment's
`auto_refresh` flag keeps membership current; a manual
`POST /contacts/segments/{id}/refresh` re-materialises immediately.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Ava",
    "last_name": "Chen",
    "email": "ava@acme.example",
    "phone": "+4915123456789",
    "lifecycle_stage": "customer",
    "country_code": "DE"
  }'
```

```typescript Node.js theme={null}
await orbit.request('POST', '/api/v1/contacts', {
  first_name: 'Ava',
  last_name: 'Chen',
  email: 'ava@acme.example',
  phone: '+4915123456789',
  lifecycle_stage: 'customer',
  country_code: 'DE',
});
```

```python Python theme={null}
client.request(
    "POST",
    "/contacts",
    json_body={
        "first_name": "Ava",
        "last_name": "Chen",
        "email": "ava@acme.example",
        "phone": "+4915123456789",
        "lifecycle_stage": "customer",
        "country_code": "DE",
    },
)
```

```go Go theme={null}
if err := client.Request(context.Background(), "POST", "/contacts", nil, map[string]any{
    "first_name":      "Ava",
    "last_name":       "Chen",
    "email":           "ava@acme.example",
    "phone":           "+4915123456789",
    "lifecycle_stage": "customer",
    "country_code":    "DE",
}, nil, ""); err != nil {
    log.Fatal(err)
}
```

```ruby Ruby theme={null}
client.request("POST", "/contacts", json_body: {
  first_name: "Ava",
  last_name: "Chen",
  email: "ava@acme.example",
  phone: "+4915123456789",
  lifecycle_stage: "customer",
  country_code: "DE"
})
```

```php PHP theme={null}
$client->request('POST', '/contacts', null, [
    'first_name' => 'Ava',
    'last_name' => 'Chen',
    'email' => 'ava@acme.example',
    'phone' => '+4915123456789',
    'lifecycle_stage' => 'customer',
    'country_code' => 'DE',
]);
```

### Step 3 — Create the campaign on the segment

`POST /campaigns` with `audience_type: "segment"` and the segment id.
The campaign is created dormant; nothing sends until step 4.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/campaigns \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "EU reactivation",
    "type": "blast",
    "channel": "sms",
    "audience_type": "segment",
    "audience_id": "seg_9f27…",
    "message_template": "Hi {{first_name}}, your Berlin order ships today."
  }'
```

```typescript Node.js theme={null}
const { data: campaign } = await orbit.request<{ data: { id: string } }>(
  'POST',
  '/api/v1/campaigns',
  {
    name: 'EU reactivation',
    type: 'blast',
    channel: 'sms',
    audience_type: 'segment',
    audience_id: segment.id,
    message_template: 'Hi {{first_name}}, your Berlin order ships today.',
  },
);
```

```python Python theme={null}
campaign = client.request(
    "POST",
    "/campaigns",
    json_body={
        "name": "EU reactivation",
        "type": "blast",
        "channel": "sms",
        "audience_type": "segment",
        "audience_id": segment_id,
        "message_template": "Hi {{first_name}}, your Berlin order ships today.",
    },
)

campaign_id = campaign["data"]["id"]
```

```go Go theme={null}
var campaign map[string]any
if err := client.Request(context.Background(), "POST", "/campaigns", nil, map[string]any{
    "name":             "EU reactivation",
    "type":             "blast",
    "channel":          "sms",
    "audience_type":    "segment",
    "audience_id":      segmentID,
    "message_template": "Hi {{first_name}}, your Berlin order ships today.",
}, &campaign, ""); err != nil {
    log.Fatal(err)
}

campaignID := campaign["data"].(map[string]any)["id"].(string)
```

```ruby Ruby theme={null}
campaign = client.request("POST", "/campaigns", json_body: {
  name: "EU reactivation",
  type: "blast",
  channel: "sms",
  audience_type: "segment",
  audience_id: segment_id,
  message_template: "Hi {{first_name}}, your Berlin order ships today."
})

campaign_id = campaign["data"]["id"]
```

```php PHP theme={null}
$campaign = $client->request('POST', '/campaigns', null, [
    'name' => 'EU reactivation',
    'type' => 'blast',
    'channel' => 'sms',
    'audience_type' => 'segment',
    'audience_id' => $segmentId,
    'message_template' => 'Hi {{first_name}}, your Berlin order ships today.',
]);

$campaignId = $campaign['data']['id'];
```

### Step 4 — Send it

`POST /campaigns/{id}/send` takes no body. Pre-launch gates (suppression
lists, frequency caps, quiet hours) run on this call exactly as they do
for a dashboard send.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/campaigns/seg_campaign_id/send \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```typescript Node.js theme={null}
await orbit.request('POST', `/api/v1/campaigns/${campaign.id}/send`);
```

```python Python theme={null}
client.request("POST", f"/campaigns/{campaign_id}/send")
```

```go Go theme={null}
if err := client.Request(context.Background(), "POST",
    fmt.Sprintf("/campaigns/%s/send", campaignID), nil, nil, nil, ""); err != nil {
    log.Fatal(err)
}
```

```ruby Ruby theme={null}
client.request("POST", "/campaigns/#{campaign_id}/send")
```

```php PHP theme={null}
$client->request('POST', "/campaigns/{$campaignId}/send");
```

### The same flow in Java and C# (pre-publish)

Both pre-publish clients expose the identical escape hatch; vendor them
from source per the [Java](https://docs.orbit.devotel.io/sdks/java.md) and [C#](https://docs.orbit.devotel.io/sdks/csharp.md) pages. The
four steps collapse to the same three shapes: a `POST` with a body, a
`POST` that reads an id back, and a bodyless `POST`.

```java Java theme={null}
Map<String, Object> segment = client.request("POST", "/contacts/segments", null, Map.of(
    "name", "High-value EU customers",
    "auto_refresh", true,
    "filters", Map.of("op", "AND", "conditions", List.of(
        Map.of("field", "lifecycle_stage", "op", "equals", "value", "customer"),
        Map.of("field", "country_code", "op", "equals", "value", "DE")))),
    null);
String segmentId = (String) ((Map<?, ?>) segment.get("data")).get("id");

Map<String, Object> campaign = client.request("POST", "/campaigns", null, Map.of(
    "name", "EU reactivation",
    "type", "blast",
    "channel", "sms",
    "audience_type", "segment",
    "audience_id", segmentId,
    "message_template", "Hi {{first_name}}, your Berlin order ships today."),
    null);
String campaignId = (String) ((Map<?, ?>) campaign.get("data")).get("id");

client.request("POST", "/campaigns/" + campaignId + "/send", null, null, null);
```

```csharp C# theme={null}
var segment = await client.RequestAsync("POST", "/contacts/segments", jsonBody: new Dictionary<string, object?>
{
    ["name"] = "High-value EU customers",
    ["auto_refresh"] = true,
    ["filters"] = new Dictionary<string, object?>
    {
        ["op"] = "AND",
        ["conditions"] = new object[]
        {
            new Dictionary<string, object?> { ["field"] = "lifecycle_stage", ["op"] = "equals", ["value"] = "customer" },
            new Dictionary<string, object?> { ["field"] = "country_code", ["op"] = "equals", ["value"] = "DE" },
        },
    },
});
var segmentId = segment.GetProperty("data").GetProperty("id").GetString();

var campaign = await client.RequestAsync("POST", "/campaigns", jsonBody: new Dictionary<string, object?>
{
    ["name"] = "EU reactivation",
    ["type"] = "blast",
    ["channel"] = "sms",
    ["audience_type"] = "segment",
    ["audience_id"] = segmentId,
    ["message_template"] = "Hi {{first_name}}, your Berlin order ships today.",
});
var campaignId = campaign.GetProperty("data").GetProperty("id").GetString();

await client.RequestAsync("POST", $"/campaigns/{campaignId}/send");
```

## 4. The envelope: unwrap `data`, keep `meta.request_id`

Every response, raw or through an SDK, is the same envelope:

```json theme={null}
{
  "data": { "id": "seg_9f27", "name": "High-value EU customers", "contact_count": 1 },
  "meta": { "request_id": "req_jkl012", "timestamp": "2026-10-25T09:00:00.000Z" }
}
```

All seven forms above return the **whole** envelope; none of the clients
unwrap it for you. Destructure `data` for the resource, and log
`meta.request_id` next to the call in your own logs: when you open a
support ticket about a specific call, the request id is what ties your
trace to the server side. List reads additionally carry
`meta.pagination` with the `next_cursor` to pass back as `?cursor=`.

```typescript Node.js — the full path, envelope and all theme={null}
const envelope = await orbit.request<{
  data: unknown;
  meta: { request_id: string };
}>('GET', '/api/v1/contacts/segments?limit=50');

console.log(envelope.meta.request_id); // req_… — keep it for support tickets
```

```python Python — query via params, path without /api/v1 theme={null}
page = client.request("GET", "/contacts/segments", params={"limit": 50})

print(page["meta"]["request_id"])
cursor = page["meta"]["pagination"]["next_cursor"]
```

```go Go theme={null}
var page map[string]any
if err := client.Request(context.Background(), "GET", "/contacts/segments",
    url.Values{"limit": {"50"}}, nil, &page, ""); err != nil {
    log.Fatal(err)
}

log.Printf("request_id=%s", page["meta"].(map[string]any)["request_id"])
```

## 5. Errors: the typed errors follow you down here

The escape hatch raises the same error types the typed helpers raise. A
`429` is retried up to the client's retry budget; when the budget runs
out you get the language's rate-limit type, and every error carries the
server's `error.code` plus `meta.request_id`:

```json theme={null}
{
  "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded", "status": 429 },
  "meta": { "request_id": "req_ghi789", "timestamp": "2026-10-25T09:01:00.000Z" }
}
```

| Language | Rate-limit check |
| - | - |
| Node.js | `catch (e) { if (e instanceof OrbitRateLimitError) … }` |
| Python | `except OrbitRateLimitError as e:` |
| Go | `if orbit.IsRateLimit(err) { … }` |
| Ruby | `rescue OrbitSdk::OrbitRateLimitError => e` |
| PHP | `catch (OrbitRateLimitError $e)` |

```typescript Node.js theme={null}
import { Orbit, OrbitRateLimitError } from '@devotel-orbit/node';

try {
  await orbit.request('POST', `/api/v1/campaigns/${campaign.id}/send`);
} catch (e) {
  if (e instanceof OrbitRateLimitError) {
    // budget exhausted; back off and re-enqueue the job
  } else {
    throw e;
  }
}
```

```python Python theme={null}
from orbit_sdk import OrbitClient, OrbitRateLimitError

try:
    client.request("POST", f"/campaigns/{campaign_id}/send")
except OrbitRateLimitError:
    ...  # back off and re-enqueue the job
```

```go Go theme={null}
if err := client.Request(context.Background(), "POST",
    fmt.Sprintf("/campaigns/%s/send", campaignID), nil, nil, nil, ""); err != nil {
    if orbit.IsRateLimit(err) {
        // back off and re-enqueue the job
    } else {
        log.Fatal(err)
    }
}
```

### Honoring a 429 without an SDK

A raw caller owns its own retry. Honour `Retry-After`, cap the attempts,
and send a stable `Idempotency-Key` on mutating calls so the retry cannot
double-send:

```bash theme={null}
attempt=0
while true; do
  status=$(curl -s -o body.json -D headers.txt -w "%{http_code}" \
    -X POST https://api.orbit.devotel.io/api/v1/contacts/segments \
    -H "X-API-Key: $ORBIT_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $JOB_ID" \
    -d @segment.json)

  [ "$status" != 429 ] && [ "$status" -lt 500 ] && break

  attempt=$((attempt + 1))
  [ "$attempt" -gt 5 ] && echo "gave up after 5 attempts" >&2 && exit 1

  wait=$(grep -i '^retry-after:' headers.txt | awk '{print $2}' | tr -d '\r')
  sleep "${wait:-$((2 ** attempt))}"
done
```

## 6. Sandbox keys vs. `X-Test-Mode`

The escape hatch behaves identically in sandbox: point `ORBIT_API_KEY`
at a sandbox secret key and every sample on this page runs against your
sandbox org with nothing delivered and no live recipient touched.

Two distinct signals, not interchangeable:

* **A sandbox key (`dv_test_sk_…`)** — the normal one for scripts and CI.
  The key prefix itself scopes the session to sandbox; the same request
  shapes work unchanged.
* **The `X-Test-Mode: true` header** — a dashboard-session signal. The
  console's Test/Live toggle sets it for you; it is honoured only on
  dashboard sessions, and a live key (`dv_live_sk_…`) cannot flip into
  sandbox with it.

Keep live keys out of test harnesses entirely: a harness that retries a
`429` against a live key is a harness that retries against production
data. The [sandbox overview](https://docs.orbit.devotel.io/guides/sandbox-test-mode.md) walks all three
activation signals; the [Sandbox API cookbook](https://docs.orbit.devotel.io/guides/sandbox-api-samples.md)
pairs every sandbox route with its escape-hatch line.

## 7. Where to go next

* [Sandbox API: worked examples](https://docs.orbit.devotel.io/guides/sandbox-api-samples.md) — this page's sibling for sandbox routes
* [SDK sample coverage policy](https://docs.orbit.devotel.io/api-reference/sdk-language-policy.md) — which languages appear on which pages, and why
* SDK pages: [Node.js](https://docs.orbit.devotel.io/sdks/node.md) · [Python](https://docs.orbit.devotel.io/sdks/python.md) · [Go](https://docs.orbit.devotel.io/sdks/go.md) · [Ruby](https://docs.orbit.devotel.io/sdks/ruby.md) · [PHP](https://docs.orbit.devotel.io/sdks/php.md) · [Java](https://docs.orbit.devotel.io/sdks/java.md) · [C#](https://docs.orbit.devotel.io/sdks/csharp.md) — each walks its escape hatch end to end


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.