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

# TED developer guide

> Implement TED integrations correctly — idempotency, retry strategy, state handling, and webhook validation patterns for reliable transfers.

This guide is for developers implementing the TED plugin integration. It covers the patterns and decisions that go beyond individual endpoint calls: idempotency, retry strategy, state handling, and webhook validation.

For endpoint parameters and response schemas, see the [API Reference](/en/reference/midaz/plugins/ted/initiate-transfer).

## Idempotency

***

Every mutating request (initiate, process, cancel) requires an `X-Idempotency` header. If you send the same key twice, the plugin returns the original response without creating a duplicate operation.

**Rules:**

* Use a UUID v4 or a unique business identifier (e.g. your internal order ID)
* Maximum length: 255 characters
* Keys are scoped per organization — the same key from two different organizations is treated as two distinct requests
* Cached responses are returned for 24 hours
* Replayed responses are byte-identical to the original (same status code and body); the response does not currently expose a header to distinguish replays from fresh executions, so design your client to be safe under either case

```http theme={null}
POST /v1/transfers/initiate
X-Organization-Id: 019c9ac2-3f5d-7df9-9215-bdccc1451def
X-Idempotency: 7f3d9a1b-4e2c-4f8a-b3d1-9e6f2a4c8b7e
```

<Warning>
  Do not reuse idempotency keys across different operations. A key used to initiate a transfer must not be reused to process or cancel it.
</Warning>

### Duplicate detection

Beyond idempotency keys, the plugin detects content-based duplicates. It generates a hash from the organization (from the `X-Organization-Id` header), `senderAccountId`, recipient details, and amount, and stores it in Redis for 5 minutes (default 300 seconds, configurable via `DUPLICATE_GUARD_TTL_SEC`). If a matching transfer was already submitted within the window, the request is rejected with `409 BTF-0012`.

This catches cases where the client sends the same transfer with a different idempotency key — for example, after a timeout where the original response was not received.

## Retry strategy

***

Use exponential backoff for transient errors. Not all errors should be retried.

| HTTP status | Error type       | Retry? | Notes                                                 |
| ----------- | ---------------- | ------ | ----------------------------------------------------- |
| `400`       | Validation error | No     | Fix the request before retrying                       |
| `404`       | Not found        | No     | Resource does not exist                               |
| `409`       | Duplicate        | No     | Idempotent — use the original response                |
| `410`       | Expired          | No     | Create a new initiation                               |
| `422`       | Business rule    | No     | Operating hours, limits — condition must change first |
| `429`       | Rate limit       | Yes    | Wait for `Retry-After` header value (seconds)         |
| `500`       | Internal error   | Yes    | Retry with backoff                                    |
| `503`       | Unavailable      | Yes    | Retry with backoff                                    |

**Recommended backoff schedule for 5xx/503:** 0s, 5s, 25s, 60s, 120s (5 attempts total).

<Note>
  When JD SPB is unavailable, the response is `HTTP 503` and the `error.code` field carries the raw JD vendor code (for example, `TRANSPORT` for transport failures or `ACE95` for timeouts) — JD-chain failures are not wrapped in a `BTF-` code. After exhausting retries, the transfer should be flagged for manual reconciliation. Do not keep retrying indefinitely — the JD SPB network has defined operating hours.
</Note>

## State handling

***

### TED OUT state machine

Transfers follow a strict progression. Once a transfer leaves `CREATED` or `PENDING`, it cannot be cancelled.

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/L16w9jbmemhqlM9_/images/en/docs/ted-state-machine-ted-out.jpg?fit=max&auto=format&n=L16w9jbmemhqlM9_&q=85&s=ee9b77240843bc814fa2c4b33df7997a" alt="TED OUT state machine" width="1781" height="877" data-path="images/en/docs/ted-state-machine-ted-out.jpg" />
</Frame>

**What to do in each state:**

| State        | Meaning                                    | Recommended action                                         |
| ------------ | ------------------------------------------ | ---------------------------------------------------------- |
| `CREATED`    | Confirmed by user, queued for submission   | Show "Processing" in UI; poll or wait for webhook          |
| `PENDING`    | Submitted to JD, awaiting acknowledgment   | Show "Processing"; do not allow cancellation               |
| `PROCESSING` | JD accepted and is routing the transfer    | Show "Processing"; typical SLA under 10 minutes            |
| `COMPLETED`  | Settled                                    | Show confirmation with `confirmationNumber`                |
| `REJECTED`   | JD rejected (invalid data, rule violation) | Show error to user; funds already released                 |
| `FAILED`     | JD unreachable or timed out                | Show error; funds already released; allow retry if desired |
| `CANCELLED`  | Cancelled before submission                | Show cancellation confirmation                             |

### Initiation state machine

The `PaymentInitiation` entity (created by the initiate endpoint) has its own lifecycle before a `Transfer` is created.

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/L16w9jbmemhqlM9_/images/en/docs/ted-state-machine-initiation.jpg?fit=max&auto=format&n=L16w9jbmemhqlM9_&q=85&s=9e5cad876c4a3360a76ce68dca113d7e" alt="Initiation state machine" width="2131" height="732" data-path="images/en/docs/ted-state-machine-initiation.jpg" />
</Frame>

### TED IN state machine

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/L16w9jbmemhqlM9_/images/en/docs/ted-state-machine-ted-in.jpg?fit=max&auto=format&n=L16w9jbmemhqlM9_&q=85&s=f6ffcad7957b3fd4ab3ff36a1279c3c7" alt="TED IN state machine" width="1895" height="824" data-path="images/en/docs/ted-state-machine-ted-in.jpg" />
</Frame>

### P2P state machine

P2P does not have a `PENDING` state. Settlement is atomic and instant.

<Frame>
  <img src="https://mintcdn.com/lerian-49cb71fc/L16w9jbmemhqlM9_/images/en/docs/ted-state-machine-ted-p2p.jpg?fit=max&auto=format&n=L16w9jbmemhqlM9_&q=85&s=08801b48480e5c33d9065cb281bfed06" alt="P2P state machine" width="1725" height="905" data-path="images/en/docs/ted-state-machine-ted-p2p.jpg" />
</Frame>

### Polling vs. webhooks

Prefer webhooks for real-time status. If webhooks are not yet configured, poll `GET /v1/transfers/{transferId}` with a maximum of 10 attempts using the same backoff schedule as retries. After 10 minutes with no terminal state (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`), flag the transfer for manual review.

See [Get Transfer](/en/reference/midaz/plugins/ted/retrieve-transfer) and [Webhooks](/en/midaz/plugins/ted/ted-webhooks).

## Webhook integration

***

For event payload schemas and the full list of events, see [Webhooks](/en/midaz/plugins/ted/ted-webhooks).

### Signature validation

Every webhook request includes two headers your endpoint must use to verify authenticity:

* `X-Webhook-Signature` — `sha256=<hex>` HMAC-SHA256 signature
* `X-Webhook-Timestamp` — Unix timestamp in seconds (UTC) when the plugin built the request

The plugin computes the signature as:

```
X-Webhook-Signature: sha256=hex(HMAC_SHA256(WEBHOOK_SIGNING_SECRET, <timestamp> + "." + <raw_body>))
```

The signed payload is the timestamp value (the literal string sent in `X-Webhook-Timestamp`), followed by a single ASCII dot (`.`), followed by the **raw request body bytes** — exactly as received, before any JSON parsing or re-encoding. Use the bytes from the wire, not a re-serialized version of the parsed object.

To validate:

1. Read `X-Webhook-Signature` and `X-Webhook-Timestamp` from the request headers.
2. Build the signed payload: `timestamp + "." + rawBody`.
3. Compute `HMAC-SHA256` over the signed payload using your `WEBHOOK_SIGNING_SECRET` and hex-encode the result.
4. Prepend `sha256=` and compare against `X-Webhook-Signature` using a constant-time equality function.
5. Reject the request if the timestamp is outside an acceptable freshness window (a 5-minute tolerance is typical) to prevent replay.

The plugin also sets `X-Webhook-Event-Type`, `X-Webhook-Routing-Key`, and `X-Webhook-Delivery-Attempt` for observability — these are not part of the signed payload and must not be used for authentication.

<AccordionGroup>
  <Accordion title="JavaScript">
    ```javascript theme={null}
    const crypto = require('crypto');
    const express = require('express');

    const TOLERANCE_SECONDS = 300; // 5 minutes

    function validateWebhook(rawBody, timestamp, signature, secret) {
      if (!timestamp || !signature) return false;

      const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
      if (Number.isNaN(ageSeconds) || ageSeconds > TOLERANCE_SECONDS) return false;

      const signedPayload = Buffer.concat([
        Buffer.from(timestamp, 'utf8'),
        Buffer.from('.', 'utf8'),
        rawBody,
      ]);

      const expected = 'sha256=' + crypto
        .createHmac('sha256', secret)
        .update(signedPayload)
        .digest('hex');

      const expectedBuf = Buffer.from(expected);
      const receivedBuf = Buffer.from(signature);
      if (expectedBuf.length !== receivedBuf.length) return false;

      return crypto.timingSafeEqual(expectedBuf, receivedBuf);
    }

    // Use raw body — not req.body (parsed JSON)
    app.post('/webhooks/ted',
      express.raw({ type: 'application/json' }),
      (req, res) => {
        const timestamp = req.headers['x-webhook-timestamp'];
        const signature = req.headers['x-webhook-signature'];

        if (!validateWebhook(req.body, timestamp, signature, process.env.WEBHOOK_SIGNING_SECRET)) {
          return res.status(401).send('Invalid signature');
        }

        const payload = JSON.parse(req.body.toString());
        // process payload...
        res.status(200).send('OK');
      }
    );
    ```
  </Accordion>

  <Accordion title="Python">
    ```python theme={null}
    import hmac
    import hashlib
    import time

    TOLERANCE_SECONDS = 300  # 5 minutes

    def validate_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
        if not timestamp or not signature:
            return False

        try:
            age = abs(int(time.time()) - int(timestamp))
        except ValueError:
            return False
        if age > TOLERANCE_SECONDS:
            return False

        signed_payload = timestamp.encode() + b"." + raw_body
        expected = "sha256=" + hmac.new(
            secret.encode(),
            signed_payload,
            hashlib.sha256,
        ).hexdigest()

        return hmac.compare_digest(expected, signature)
    ```
  </Accordion>

  <Accordion title="Go">
    ```go theme={null}
    import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/hex"
        "strconv"
        "time"
    )

    const toleranceSeconds = 300 // 5 minutes

    func validateWebhook(rawBody []byte, timestamp, signature, secret string) bool {
        if timestamp == "" || signature == "" {
            return false
        }

        ts, err := strconv.ParseInt(timestamp, 10, 64)
        if err != nil {
            return false
        }
        if diff := time.Now().Unix() - ts; diff < -toleranceSeconds || diff > toleranceSeconds {
            return false
        }

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

        return hmac.Equal([]byte(expected), []byte(signature))
    }
    ```
  </Accordion>
</AccordionGroup>

### Idempotent webhook processing

Your endpoint may receive the same event more than once (at-least-once delivery). Use `transferId` + `event` as a composite key to deduplicate.

```javascript theme={null}
const alreadyProcessed = await db.webhookEvents.exists({
  transferId: payload.transferId,
  event: payload.type,
});

if (alreadyProcessed) {
  return res.status(200).send('OK'); // acknowledge without reprocessing
}
```

## Error handling patterns

***

Map API error codes to user-facing actions. See the [full error list](/en/reference/midaz/plugins/ted/ted-error-list) for all codes.

| Scenario                | Code                     | User-facing message                                                        | Action                                                                                                      |
| ----------------------- | ------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Outside operating hours | `BTF-0010`               | "Transfers available Mon–Fri, 06:30–17:00 (Brasília). Next window: {time}" | Show next available time                                                                                    |
| Daily limit exceeded    | `BTF-0011`               | "Daily transfer limit reached. Try again tomorrow."                        | Show remaining limit                                                                                        |
| Duplicate transfer      | `BTF-0012`               | "This transfer was already submitted."                                     | Return original `transferId`                                                                                |
| Invalid recipient data  | `BTF-0001`               | "Check recipient details and try again."                                   | Highlight invalid fields                                                                                    |
| Initiation expired      | `BTF-0202`               | "Session expired. Please start a new transfer."                            | Restart initiation flow                                                                                     |
| JD SPB unavailable      | `TRANSPORT` (HTTP `503`) | "Transfer service temporarily unavailable. Try again in a few minutes."    | Retry with backoff; detect via `503` + raw JD vendor code (`TRANSPORT`, `ACE95`, …), not by a `BTF-` prefix |
| Midaz unavailable       | `BTF-2000`               | "Service temporarily unavailable. Try again in a few minutes."             | Retry with backoff                                                                                          |

Error responses follow this structure:

```json theme={null}
{
  "error": {
    "code": "BTF-0010",
    "message": "Transfers can only be initiated Monday-Friday between 06:30 and 17:00 Brasília time",
    "fields": {
      "currentTime": "2026-01-21T18:30:00-03:00",
      "nextAvailableTime": "2026-01-22T06:30:00-03:00"
    }
  }
}
```

## Go-live checklist

***

Before enabling the integration in production:

* [ ] `X-Idempotency` is sent on every initiate, process, and cancel request
* [ ] Retry logic implemented with exponential backoff for 5xx/503 errors
* [ ] Webhook endpoint deployed and returning `200` within 5 seconds
* [ ] Signature validation active on the webhook endpoint
* [ ] Webhook event deduplication implemented using `transferId + event`
* [ ] Operating hours validated client-side before calling initiate (reduces unnecessary 422s)
* [ ] Both `transferId` and `confirmationNumber` stored for reconciliation
* [ ] Terminal states (`COMPLETED`, `REJECTED`, `FAILED`, `CANCELLED`) handled in UI
* [ ] Initiation expiry (24h) handled — user is prompted to restart if window passes
* [ ] Service readiness monitored in your alerting system for BYOC deployments
* [ ] Redis is available and monitored — service will not accept requests if Redis is unreachable
* [ ] `PLUGIN_AUTH_ENABLED=true` configured in production, with valid `PLUGIN_AUTH_ADDRESS`, `PLUGIN_AUTH_CLIENT_ID`, and `PLUGIN_AUTH_CLIENT_SECRET`
