Skip to main content

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.

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.

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
POST /v1/transfers/initiate
X-Organization-Id: 019c9ac2-3f5d-7df9-9215-bdccc1451def
X-Idempotency: 7f3d9a1b-4e2c-4f8a-b3d1-9e6f2a4c8b7e
Do not reuse idempotency keys across different operations. A key used to initiate a transfer must not be reused to process or cancel it.

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 statusError typeRetry?Notes
400Validation errorNoFix the request before retrying
404Not foundNoResource does not exist
409DuplicateNoIdempotent — use the original response
410ExpiredNoCreate a new initiation
422Business ruleNoOperating hours, limits — condition must change first
429Rate limitYesWait for Retry-After header value (seconds)
500Internal errorYesRetry with backoff
503UnavailableYesRetry with backoff; check /health/ready if persists
Recommended backoff schedule for 5xx/503: 0s, 5s, 25s, 60s, 120s (5 attempts total).
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.

State handling


TED OUT state machine

Transfers follow a strict progression. Once a transfer leaves CREATED or PENDING, it cannot be cancelled.
TED OUT state machine
What to do in each state:
StateMeaningRecommended action
CREATEDConfirmed by user, queued for submissionShow “Processing” in UI; poll or wait for webhook
PENDINGSubmitted to JD, awaiting acknowledgmentShow “Processing”; do not allow cancellation
PROCESSINGJD accepted and is routing the transferShow “Processing”; typical SLA under 10 minutes
COMPLETEDSettledShow confirmation with confirmationNumber
REJECTEDJD rejected (invalid data, rule violation)Show error to user; funds already released
FAILEDJD unreachable or timed outShow error; funds already released; allow retry if desired
CANCELLEDCancelled before submissionShow cancellation confirmation

Initiation state machine

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

TED IN state machine

TED IN state machine

P2P state machine

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

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 and Webhooks.

Webhook integration


For event payload schemas and the full list of events, see Webhooks.

Signature validation

Every webhook request includes two headers your endpoint must use to verify authenticity:
  • X-Webhook-Signaturesha256=<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.
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');
  }
);
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)
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))
}

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.
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 for all codes.
ScenarioCodeUser-facing messageAction
Outside operating hoursBTF-0010”Transfers available Mon–Fri, 06:30–17:00 (Brasília). Next window: Show next available time
Daily limit exceededBTF-0011”Daily transfer limit reached. Try again tomorrow.”Show remaining limit
Duplicate transferBTF-0012”This transfer was already submitted.”Return original transferId
Invalid recipient dataBTF-0001”Check recipient details and try again.”Highlight invalid fields
Initiation expiredBTF-0202”Session expired. Please start a new transfer.”Restart initiation flow
JD SPB unavailableTRANSPORT (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 unavailableBTF-2000”Service temporarily unavailable. Try again in a few minutes.”Retry with backoff
Error responses follow this structure:
{
  "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
  • GET /health/ready 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