Skip to content

SPI Service

The SPI Service (apps/spi_service) processes real-time instant payments through the BACEN SPI (Sistema de Pagamentos Instantaneos) infrastructure. It runs on port 4002 and handles payment initiation, confirmation, return, and QR Code operations.

Responsibilities

  • Payment initiation via ISO 20022 pacs.008 messages
  • Payment confirmation processing (pacs.002)
  • Payment return and reversal (pacs.004)
  • QR Code generation (static and dynamic)
  • Real-time position management
  • Webhook delivery for payment status changes
  • End-to-end ID generation and validation

Payment Flow

Outbound Payment (Sending)

┌──────────┐     ┌────────────┐     ┌──────────┐     ┌──────┐
│  Client   │────>│ SPI Service│────>│  BACEN   │────>│Recvr │
│  Request  │     │  pacs.008  │     │   SPI    │     │ PSP  │
└──────────┘     └────────────┘     └──────────┘     └──────┘
                       │                  │
                       │    pacs.002      │
                       │<─────────────────│
                       │                  │
                  ┌────▼────┐
                  │ Confirm │
                  │ & Notify│
                  └─────────┘

Inbound Payment (Receiving)

┌──────┐     ┌──────────┐     ┌────────────┐     ┌──────────┐
│Sender│────>│  BACEN   │────>│ SPI Service│────>│ Account  │
│ PSP  │     │   SPI    │     │  pacs.008  │     │ Credit   │
└──────┘     └──────────┘     └────────────┘     └──────────┘

                               ┌────▼────┐
                               │pacs.002 │
                               │ Response│
                               └─────────┘

Payment Initiation

Creating a Payment

The SPI Service validates the payment request, resolves the destination via DICT, constructs the ISO 20022 message, signs it, and sends it to BACEN:

elixir
defmodule SpiService.Payments do
  alias Shared.Repo
  alias Shared.Schema.Transaction

  def initiate_payment(params) do
    with {:ok, validated} <- validate_payment(params),
         {:ok, dest_info} <- resolve_destination(validated),
         {:ok, transaction} <- create_transaction(validated, dest_info),
         {:ok, message} <- build_pacs008(transaction),
         {:ok, signed} <- sign_message(message),
         {:ok, response} <- send_to_bacen(signed) do
      update_transaction(transaction, :sent, response)
      Shared.Nats.publish("pix.payment.initiated", transaction)
      {:ok, transaction}
    else
      {:error, reason} -> handle_initiation_error(reason, params)
    end
  end

  defp validate_payment(params) do
    validations = [
      &validate_amount/1,
      &validate_idempotency/1,
      &validate_debtor_account/1,
      &validate_not_duplicate/1
    ]

    Enum.reduce_while(validations, {:ok, params}, fn validator, {:ok, p} ->
      case validator.(p) do
        {:ok, p} -> {:cont, {:ok, p}}
        {:error, _} = err -> {:halt, err}
      end
    end)
  end
end

Payment Validation Rules

RuleDescription
Amount rangeBRL 0.01 to BRL 999,999,999.99
IdempotencyDuplicate end-to-end IDs rejected within 24h
Account statusDebtor account must be active and not blocked
Balance checkSufficient available balance required
Rate limitPer-account and per-institution limits enforced
Fraud checkAnti-fraud markers validated against DICT

End-to-End ID

The end-to-end ID (E2EID) uniquely identifies each PIX payment. It follows the BACEN-defined format:

E{ISPB}{TIMESTAMP}{SEQUENCE}

Where:
- E         = Fixed prefix
- ISPB      = 8-digit participant ISPB
- TIMESTAMP = YYYYMMDDHHmm (creation timestamp)
- SEQUENCE  = 11 alphanumeric characters
elixir
defmodule SpiService.E2EId do
  def generate(ispb) do
    timestamp = Calendar.strftime(DateTime.utc_now(), "%Y%m%d%H%M")
    sequence = :crypto.strong_rand_bytes(8) |> Base.encode32(padding: false) |> String.slice(0, 11)
    "E#{ispb}#{timestamp}#{sequence}"
  end

  def parse(e2e_id) do
    case e2e_id do
      <<"E", ispb::binary-size(8), timestamp::binary-size(12), sequence::binary>> ->
        {:ok, %{ispb: ispb, timestamp: parse_timestamp(timestamp), sequence: sequence}}
      _ ->
        {:error, :invalid_e2e_id}
    end
  end
end

ISO 20022 Messages

pacs.008 - Payment Initiation

The credit transfer message initiates a PIX payment:

xml
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.008.001.09">
  <FIToFICstmrCdtTrf>
    <GrpHdr>
      <MsgId>MSG20240115120000ABC</MsgId>
      <CreDtTm>2024-01-15T12:00:00Z</CreDtTm>
      <NbOfTxs>1</NbOfTxs>
      <SttlmInf>
        <SttlmMtd>CLRG</SttlmMtd>
      </SttlmInf>
    </GrpHdr>
    <CdtTrfTxInf>
      <PmtId>
        <EndToEndId>E1234567820240115120012345678901</EndToEndId>
        <TxId>TXN20240115ABC123</TxId>
      </PmtId>
      <IntrBkSttlmAmt Ccy="BRL">150.00</IntrBkSttlmAmt>
      <ChrgBr>SLEV</ChrgBr>
      <Dbtr>
        <Nm>John Doe</Nm>
        <Id><PrvtId><Othr><Id>12345678901</Id></Othr></PrvtId></Id>
      </Dbtr>
      <DbtrAgt><FinInstnId><ClrSysMmbId><MmbId>12345678</MmbId></ClrSysMmbId></FinInstnId></DbtrAgt>
      <CdtrAgt><FinInstnId><ClrSysMmbId><MmbId>87654321</MmbId></ClrSysMmbId></FinInstnId></CdtrAgt>
      <Cdtr>
        <Nm>Jane Smith</Nm>
      </Cdtr>
    </CdtTrfTxInf>
  </FIToFICstmrCdtTrf>
</Document>

pacs.002 - Payment Status Report

Received from BACEN confirming payment acceptance or rejection:

elixir
defmodule SpiService.Handler.Pacs002 do
  def handle(xml_message) do
    with {:ok, parsed} <- parse_status_report(xml_message),
         {:ok, transaction} <- find_transaction(parsed.original_e2e_id) do
      case parsed.status do
        "ACSP" -> confirm_payment(transaction)     # Accepted Settlement in Process
        "RJCT" -> reject_payment(transaction, parsed.reason)  # Rejected
        "ACCC" -> complete_payment(transaction)     # Accepted Credit Completed
      end
    end
  end
end

pacs.004 - Payment Return

Used for payment reversals, including MED-triggered returns:

elixir
defmodule SpiService.Returns do
  @return_reasons %{
    "MD06" => :refund_request,
    "FR01" => :fraud,
    "AC03" => :invalid_creditor_account,
    "AM09" => :wrong_amount,
    "BE08" => :wrong_creditor
  }

  def initiate_return(original_e2e_id, reason, amount \\ nil) do
    with {:ok, original} <- find_original_transaction(original_e2e_id),
         amount <- amount || original.amount,
         {:ok, return_msg} <- build_pacs004(original, reason, amount),
         {:ok, signed} <- sign_message(return_msg),
         {:ok, response} <- send_to_bacen(signed) do
      create_return_record(original, reason, amount, response)
    end
  end
end

QR Code Operations

Static QR Code

Static QR codes contain fixed payment information and can be reused:

elixir
defmodule SpiService.QrCode.Static do
  def generate(params) do
    payload = %{
      "pixKey" => params.key,
      "merchantName" => params.merchant_name,
      "merchantCity" => params.merchant_city,
      "amount" => params.amount,  # optional for static
      "description" => params.description
    }

    emv_string = encode_emv(payload)
    qr_image = QRCode.create(emv_string) |> QRCode.render(:png)

    {:ok, %{emv: emv_string, image: qr_image}}
  end
end

Dynamic QR Code

Dynamic QR codes are single-use with a specific amount and expiration:

elixir
defmodule SpiService.QrCode.Dynamic do
  def generate(params) do
    # Create a payment location (loc) with BACEN
    {:ok, loc} = create_payment_location(params)

    payload = %{
      "url" => loc.url,
      "amount" => params.amount,
      "expiration" => params.expires_in,
      "payerRequest" => params.payer_message
    }

    emv_string = encode_emv(payload)
    qr_image = QRCode.create(emv_string) |> QRCode.render(:png)

    {:ok, %{emv: emv_string, image: qr_image, location: loc}}
  end
end

Position Management

The SPI Service tracks the institution's real-time position (balance) with BACEN:

elixir
defmodule SpiService.PositionManager do
  use GenServer

  def init(_opts) do
    {:ok, %{position: fetch_initial_position()}}
  end

  def handle_call({:check_balance, amount}, _from, state) do
    available = state.position.available - state.position.reserved
    result = if available >= amount, do: :ok, else: {:error, :insufficient_position}
    {:reply, result, state}
  end

  def handle_cast({:debit, amount, e2e_id}, state) do
    new_position = %{state.position |
      available: state.position.available - amount,
      reserved: state.position.reserved + amount,
      pending: Map.put(state.position.pending, e2e_id, amount)
    }
    {:noreply, %{state | position: new_position}}
  end

  def handle_cast({:confirm, e2e_id}, state) do
    amount = Map.get(state.position.pending, e2e_id, 0)
    new_position = %{state.position |
      reserved: state.position.reserved - amount,
      pending: Map.delete(state.position.pending, e2e_id)
    }
    {:noreply, %{state | position: new_position}}
  end
end

Webhook Notifications

Payment status changes trigger webhook notifications to registered endpoints:

elixir
defmodule SpiService.Webhooks do
  def notify(transaction, event) do
    payload = %{
      event: event,
      e2e_id: transaction.end_to_end_id,
      amount: transaction.amount,
      status: transaction.status,
      timestamp: DateTime.utc_now()
    }

    Enum.each(registered_webhooks(transaction.account_id), fn webhook ->
      Task.Supervisor.start_child(SpiService.WebhookSupervisor, fn ->
        deliver_with_retry(webhook.url, payload, max_retries: 5)
      end)
    end)
  end
end

Telemetry

MetricTypeDescription
spi.payment.initiated.countCounterPayments initiated
spi.payment.confirmed.countCounterPayments confirmed
spi.payment.rejected.countCounterPayments rejected
spi.payment.returned.countCounterPayments returned
spi.payment.durationHistogramEnd-to-end payment latency
spi.bacen.request.durationHistogramBACEN SPI API call latency
spi.position.availableGaugeCurrent available position
spi.qrcode.generated.countCounterQR codes generated
spi.webhook.delivered.countCounterWebhooks delivered

FluxiQ PIX - Plataforma Brasileira de Pagamento Instantaneo