> ## 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.

# Security Best Practices

> API key management, webhook verification, and security hardening for Orbit integrations

# Security Best Practices

Protect your Orbit integration with proper API key management, webhook signature verification, and security hardening.

## API Key Management

### Key Types

Orbit uses four types of API keys:

| Key Type        | Prefix        | Usage                            | Permissions       |
| --------------- | ------------- | -------------------------------- | ----------------- |
| Live Secret Key | `dv_live_sk_` | Server-side only                 | Full API access   |
| Test Secret Key | `dv_test_sk_` | Server-side testing              | Sandbox only      |
| Live Public Key | `dv_live_pk_` | Client-side (web SDK)            | Limited read-only |
| Test Public Key | `dv_test_pk_` | Client-side (web SDK, test mode) | Limited read-only |

### Key Security Rules

1. **Never expose secret keys in client-side code.** Secret keys (`sk_`) should only be used in server-side environments.

```
❌ Client-side JavaScript
const orbit = new Devotel({ apiKey: 'dv_live_sk_xxxx' }) // NEVER

✅ Server-side only
// Node.js backend, Python server, etc.
const orbit = new Devotel({ apiKey: process.env.ORBIT_API_KEY })
```

2. **Use environment variables.** Never hardcode keys in source code.

```bash theme={null}
# .env (never commit this file)
ORBIT_API_KEY=dv_live_sk_your_key_here
```

3. **Rotate keys regularly.** Generate new keys quarterly and revoke old ones.

4. **Use separate keys per environment.** Use test keys (`dv_test_sk_`) for development.

5. **Restrict key permissions.** Create keys with minimum required scopes in the dashboard under **Settings > API Keys**.

### Revoking a Compromised Key

If a key is exposed:

1. Go to **Settings > API Keys** in the Orbit dashboard
2. Click **Revoke** on the compromised key
3. Generate a new key
4. Update your application configuration immediately

<Warning>
  Revoking a key is immediate and irreversible. All requests using the revoked key will return `401 INVALID_API_KEY`.
</Warning>

***

## Webhook Verification

Every webhook from Orbit carries an HMAC-SHA256 signature in the `X-Orbit-Signature` header — the canonical, productised signature. For back-compat we also emit `X-Devotel-Signature` with the same value, so older integrations that read it keep working. Verify the signature (from either header) before processing webhook payloads.

### Signature Format

The header is **Stripe-style**, not a bare hex digest:

```
X-Orbit-Signature: t=1715357600,v1=4f9c2e6b8a1d3f5e7c9b1a3d5f7e9c1b3a5d7f9e1c3b5a7d9f1e3c5b7a9d1f3e
```

* `t=<unix>` — Unix timestamp (seconds) when Orbit signed the payload. Use this for replay protection.
* `v1=<hex>` — HMAC-SHA256 of `<t>.<raw_body>` keyed by your webhook signing secret. Hex-encoded.

`X-Devotel-Signature` carries the identical `t=...,v1=...` value for back-compat. Prefer `X-Orbit-Signature`, and fall back to `X-Devotel-Signature` when the canonical header is absent.

During a secret rotation grace window the signature includes two `v1=<hex>` values (new secret first, previous secret second). Your verifier should accept the request if **any** `v1` matches your computed HMAC. Orbit additionally sends a standalone `X-Orbit-Signature-Next` header during the grace window carrying the rotation-candidate signature; it is omitted outside the window, and verifiers that already iterate the `v1` candidates can ignore it.

### How Verification Works

1. Parse `X-Orbit-Signature` (or the back-compat `X-Devotel-Signature`) into `t` and one or more `v1` candidates.
2. Reject the request if `t` is older than 5 minutes (replay protection).
3. Compute `expected = HMAC_SHA256(secret, "<t>.<raw_body>")` as hex.
4. Compare each `v1` candidate against `expected` using a timing-safe comparison.

### Implementation

#### Node.js

```javascript theme={null}
import crypto from 'node:crypto'

function parseSignatureHeader(header) {
  const parts = header.split(',').map((p) => p.trim())
  let t = null
  const v1s = []
  for (const part of parts) {
    const eq = part.indexOf('=')
    if (eq < 0) continue
    const key = part.slice(0, eq)
    const value = part.slice(eq + 1)
    if (key === 't') t = value
    else if (key === 'v1') v1s.push(value)
  }
  return { t, v1s }
}

function verifyWebhookSignature(rawBody, header, secret) {
  const { t, v1s } = parseSignatureHeader(header)
  if (!t || v1s.length === 0) return false

  const ageSec = Math.floor(Date.now() / 1000) - Number(t)
  if (!Number.isFinite(ageSec) || ageSec < 0 || ageSec > 5 * 60) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex')
  const expectedBuf = Buffer.from(expected, 'hex')

  return v1s.some((candidate) => {
    const candidateBuf = Buffer.from(candidate, 'hex')
    return (
      candidateBuf.length === expectedBuf.length &&
      crypto.timingSafeEqual(candidateBuf, expectedBuf)
    )
  })
}

app.post('/webhooks/orbit', (req, res) => {
  // Prefer the canonical header; fall back to the back-compat one.
  const header =
    req.headers['x-orbit-signature'] || req.headers['x-devotel-signature']
  const isValid = verifyWebhookSignature(
    req.rawBody,
    header,
    'whsec_your_secret',
  )

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  // Process the webhook
  const event = JSON.parse(req.rawBody)
  res.status(200).json({ received: true })
})
```

#### Python

```python theme={null}
import hmac
import hashlib
import time


def parse_signature_header(header: str):
    t = None
    v1s = []
    for part in header.split(","):
        part = part.strip()
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            v1s.append(value)
    return t, v1s


def verify_webhook_signature(raw_body: bytes, header: str, secret: str) -> bool:
    t, v1s = parse_signature_header(header)
    if not t or not v1s:
        return False

    age = int(time.time()) - int(t)
    if age < 0 or age > 5 * 60:
        return False

    signed = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    return any(hmac.compare_digest(candidate, expected) for candidate in v1s)


@app.route("/webhooks/orbit", methods=["POST"])
def handle_webhook():
    # Prefer the canonical header; fall back to the back-compat one.
    header = request.headers.get("X-Orbit-Signature") or request.headers.get(
        "X-Devotel-Signature", ""
    )
    if not verify_webhook_signature(request.data, header, "whsec_your_secret"):
        return jsonify({"error": "Invalid signature"}), 401

    event = request.get_json()
    return jsonify({"received": True}), 200
```

#### Go

```go theme={null}
func parseSignatureHeader(header string) (string, []string) {
    var t string
    var v1s []string
    for _, part := range strings.Split(header, ",") {
        part = strings.TrimSpace(part)
        eq := strings.IndexByte(part, '=')
        if eq < 0 {
            continue
        }
        key, value := part[:eq], part[eq+1:]
        switch key {
        case "t":
            t = value
        case "v1":
            v1s = append(v1s, value)
        }
    }
    return t, v1s
}

func verifyWebhookSignature(rawBody []byte, header, secret string) bool {
    t, v1s := parseSignatureHeader(header)
    if t == "" || len(v1s) == 0 {
        return false
    }

    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil {
        return false
    }
    age := time.Now().Unix() - ts
    if age < 0 || age > 5*60 {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(t + "."))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))

    for _, candidate := range v1s {
        if hmac.Equal([]byte(candidate), []byte(expected)) {
            return true
        }
    }
    return false
}
```

### Webhook Security Checklist

* [ ] Read `X-Orbit-Signature` (the canonical header), falling back to the back-compat `X-Devotel-Signature`
* [ ] Parse the signature as `t=<unix>,v1=<hex>[,v1=<hex>]` — never as a bare hex digest
* [ ] Reject signatures whose `t` (timestamp) is older than 5 minutes (replay protection)
* [ ] Accept the request if **any** `v1` matches (covers secret rotation grace)
* [ ] Use `timingSafeEqual` (or equivalent) to prevent timing attacks
* [ ] Respond with `200` within 30 seconds to avoid retries
* [ ] Use HTTPS for all webhook endpoints
* [ ] Store webhook secrets securely (environment variables, secret manager)

***

## Transport Security

### HTTPS Required

All Orbit API endpoints require HTTPS (`https://api.orbit.devotel.io`). HTTP requests are rejected.

### TLS Requirements

* Minimum TLS 1.2
* TLS 1.3 recommended
* Weak cipher suites are not supported

### Webhook Endpoint Requirements

Your webhook endpoints must:

* Use HTTPS with a valid SSL certificate (not self-signed)
* Support TLS 1.2 or higher
* Respond within 30 seconds
* Return a `2xx` status code to acknowledge receipt

***

## IP Allowlisting

If your firewall restricts inbound traffic, allowlist Orbit's webhook delivery IPs:

```
34.141.0.0/16    (europe-west1 — primary)
35.195.0.0/16    (europe-west1 — secondary)
```

<Note>
  IP ranges may change. Subscribe to the [Orbit status page](https://status.orbit.devotel.io) for notifications about infrastructure changes.
</Note>

***

## Data Security

### PII Handling

* Orbit masks phone numbers in logs (`+1415555****`)
* API responses include full data; mask PII in your own logs
* Use metadata fields for internal identifiers instead of PII

### Data Retention

| Data Type             | Retention | Notes                            |
| --------------------- | --------- | -------------------------------- |
| SMS content           | 365 days  | Configurable per account         |
| MMS content           | 365 days  | Configurable per account         |
| WhatsApp content      | 30 days   | Configurable per account         |
| RCS content           | 30 days   | Configurable per account         |
| Viber content         | 30 days   | Configurable per account         |
| Voice content         | 90 days   | Configurable per account         |
| Email content         | 90 days   | Configurable per account         |
| Call recordings       | 30 days   | Configurable; auto-deleted after |
| Webhook delivery logs | 30 days   | Available in dashboard           |
| API access logs       | 1 year    | Includes request IDs             |

### Encryption

* **In transit:** TLS 1.2+ for all API and webhook traffic
* **At rest:** AES-256 encryption for all stored data
* **Recordings:** Encrypted at rest with per-tenant keys

***

## RBAC (Role-Based Access Control)

Orbit supports ten roles. Five are general-access roles with increasing permissions:

| Role        | API Keys      | Send Messages | Manage Agents | Billing | Team   |
| ----------- | ------------- | ------------- | ------------- | ------- | ------ |
| `viewer`    | Read only     | No            | No            | No      | No     |
| `developer` | Create/manage | Yes           | Yes           | No      | No     |
| `admin`     | Full access   | Yes           | Yes           | View    | Invite |
| `billing`   | No            | No            | No            | Full    | No     |
| `owner`     | Full access   | Yes           | Yes           | Full    | Full   |

### Specialized seats (least privilege)

The remaining five are least-privilege operator seats. Each is ranked **below `viewer`** in the role hierarchy, so it grants **only** the surfaces it is explicitly assigned to — it does **not** inherit the general read routes (analytics, messages, numbers, …) that `viewer` and above receive. None of these seats can configure voice routing, send messages, manage billing, or manage the team, and none can alter outbound voice/SMS routing.

| Role         | Purpose                                 | Scope                                                                                       |
| ------------ | --------------------------------------- | ------------------------------------------------------------------------------------------- |
| `supervisor` | CCaaS supervisor overseeing live agents | Call-flip, barge, queue control, eval scorecards, supervisor takeover + whisper; voice read |
| `marketer`   | Campaign and data-quality operator      | Campaigns read, data-quality                                                                |
| `analyst`    | CDP / computed-traits reporting         | Insights read, computed-traits read                                                         |
| `agent`      | CCaaS voice-console agent seat          | Call-park, call-flip, hot-desking, paging groups, shared lines; voice read                  |
| `user`       | Base authenticated seat                 | Push / device-telemetry self-service                                                        |

Assign the minimum role needed for each team member.

***

## Security Headers

Orbit API responses include security headers:

| Header                      | Value                                          |
| --------------------------- | ---------------------------------------------- |
| `X-Request-Id`              | Unique request identifier for tracing          |
| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` |
| `X-Content-Type-Options`    | `nosniff`                                      |
| `X-Frame-Options`           | `DENY`                                         |

<Tip>
  Found a security vulnerability? Report it to [security@devotel.io](mailto:security@devotel.io). We take all reports seriously and respond within 24 hours.
</Tip>
