Skip to main content
This guide is for developers implementing the Bank Transfer 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 to the effective organization — the same key from two different organizations is treated as two distinct requests
  • Cached responses are returned for the configured idempotency window (IDEMPOTENCY_RETRY_WINDOW_SEC, default 300 seconds)
  • 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 builds a fingerprint from:
  • senderAccountId
  • recipient details (ISPB, branch, account, holder document)
  • amount
  • purpose
The fingerprint is stored in Redis for 5 minutes (default 300 seconds, configurable via DUPLICATE_GUARD_TTL_SEC). The organization is not part of the fingerprint — tenant isolation comes from the Redis key prefix. 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 statusRetry?Notes
400NoValidation error — fix the request before retrying
404NoNot found — the resource does not exist
409NoDuplicate — idempotent; use the original response
410NoExpired — create a new initiation
422NoBusiness rule (operating hours, limits) — the condition must change first
429YesRate limit — wait for the Retry-After header value (seconds)
500YesInternal error — retry with backoff
503YesUnavailable — retry with backoff
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 headers your endpoint uses to verify authenticity:
  • X-Webhook-Signature — versioned HMAC-SHA256 signature in the form v1,sha256=<hex>
  • X-Webhook-Timestamp — Unix timestamp in seconds (UTC) when the plugin built the request
  • X-Webhook-Event — the event type (for example, transfer.completed); not part of the signature
The plugin computes the signature as:
X-Webhook-Signature: v1,sha256=hex(HMAC_SHA256(WEBHOOK_SIGNING_SECRET, "v1:" + <timestamp> + "." + <raw_body>))
The signed string is the literal prefix v1:, followed by the timestamp value (the string sent in X-Webhook-Timestamp), a single ASCII dot (.), and 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 string: "v1:" + timestamp + "." + rawBody.
  3. Compute HMAC-SHA256 over the signed string using your WEBHOOK_SIGNING_SECRET and hex-encode the result.
  4. Prepend v1,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.
Aside from X-Webhook-Signature and X-Webhook-Timestamp, the plugin sets only X-Webhook-Event (the event type). It does not send X-Webhook-Event-Type, X-Webhook-Routing-Key, or X-Webhook-Delivery-Attempt.
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('v1:', 'utf8'),
    Buffer.from(timestamp, 'utf8'),
    Buffer.from('.', 'utf8'),
    rawBody,
  ]);

  const expected = 'v1,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 = b"v1:" + timestamp.encode() + b"." + raw_body
    expected = "v1,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("v1:"))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(rawBody)
    expected := "v1,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.
ScenarioUser-facing messageAction
Outside operating hours (BTF-0010)“Transfers available Mon–Fri, 06:30–17:00 (Brasília). Next window: 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 a BTF- prefix
Midaz unavailable (BTF-2000)“Service temporarily unavailable. Try again in a few minutes.”Retry with backoff
Error responses follow this structure:
{
  "error": {
    "code": "BTF-0010",
    "service": "plugin",
    "category": "deterministic",
    "message": "Transfers can only be initiated Monday-Friday between 06:30 and 17:00 Brasília time",
    "requestId": "6d3e2a68-1f2b-4c3d-9e4f-5a6b7c8d9e0f",
    "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 a valid PLUGIN_AUTH_ADDRESS (HTTPS)