Skip to content
s1ns3nz0 | Known Unknowns
Go back

Lightning Payments for OpenCTI (2) - From a Scan Order to a Lightning Invoice

4 min read

Part 2 of the series. Part 1 covered Aperture as a payment gate.

Connecting Aperture to our service gives us a payment gate. The next step is to connect that gate to a specific order and its price.

Our service combines OpenCTI threat intelligence with ASM scanning. Authenticated users can request scans, while paid options provide additional verification or deeper analysis.

This walkthrough follows how the application prepares a Lightning payment for a scan order. The examples illustrate the request flow rather than a captured transaction.

1. The application creates a quote

Before requesting payment, the application determines what the customer is buying and how much it costs.

The customer requests a quote through:

POST /api/v1/quotes

The server looks up its configured price using the product package, profile version, and payment method. It stores the result in a quote snapshot: a record of the agreed details at that point.

Customer selects a scan scope and payment method
        ↓
Server looks up the configured price
        ↓
Server saves a quote

The customer does not supply the price. The request accepts the scan scope, payment method, and optional consent information. Unexpected fields are rejected.

This separates choosing a product from deciding its price. The customer selects what to buy; the server determines the amount.

2. The customer creates an order

The customer creates an order using the saved quote:

POST /api/v1/orders
Idempotency-Key: <unique-request-key>
Content-Type: application/json

{
  "quote_id": "<quote-id>"
}

The order starts in the awaiting_payment state.

The Idempotency-Key helps the application recognize repeated requests. For example, a client might retry because the server created the order but its response never reached the client.

Creating an order does not create a Lightning invoice. At this point, the application has recorded what the customer intends to purchase. Payment instructions come next.

3. The customer requests payment instructions

To pay with Lightning, the customer calls:

POST /api/v1/orders/<order-id>/payment/l402

The request is authenticated with the workspace credential. Initially, it contains no L402 payment proof.

Before contacting the payment infrastructure, the application creates a payment challenge record in the issuing state. This means payment instructions are being prepared.

It also constructs an internal request path that identifies the payment context:

if rail == "l402":
    resource_path = f"/paid/l402/{who.tenant_id}/{order_id}/{snapshot['scope_hash']}/{challenge_id}"
    initial_requirements = {"resource_path": resource_path}

Each component has a specific purpose:

ComponentMeaning
tenant_idWhich tenant owns the request
order_idWhich order is being paid
scope_hashWhich saved scan scope the request refers to
challenge_idWhich payment challenge is being issued

Including these identifiers does not itself prove authorization. The application validates that the tenant, order, scope, and challenge belong together.

4. The request reaches Aperture

The application sends the request through an internal payment gateway:

OpenCTI API
    ↓
Internal payment gateway :8090
    ↓
Aperture :8081

Aperture matches the request against its configured service:

services:
  - name: "opencti-paid-l402"
    pathregexp: '^/paid/l402/.*$'

    dynamicprice:
      enabled: true
      grpcaddress: "payment-aperture-services:10010"
      insecure: true

This configuration tells Aperture to ask the pricing service for the cost of requests whose paths start with /paid/l402/.

The request identifies the order. It does not tell Aperture to trust a customer-selected amount.

Here, insecure: true applies to the internal gRPC connection to the pricing service. It does not disable L402 authentication.

5. Aperture asks for the order’s price

The pricing service receives the request path and looks up its payment context through OpenCTI’s private endpoint:

/internal/payments/l402/challenge-lookup

OpenCTI validates the challenge and returns the amount stored in the quote.

The pricing service then returns that amount to Aperture:

result, err := s.lookup(ctx, in.GetPath(), "issue", workspace)
if err != nil {
    return nil, status.Error(
        codes.FailedPrecondition,
        "challenge lookup rejected",
    )
}

return &pricesrpc.GetPriceResponse{
    PriceSats: result.AmountSats,
}, nil

The exchange can be understood as a short conversation:

Aperture:
    “How much does this request cost?”
        ↓
Pricing service:
    “I will look up its challenge and order.”
        ↓
OpenCTI:
    “Here is the amount saved in the quote.”
        ↓
Pricing service:
    “Return that amount to Aperture as PriceSats.”

Dynamic pricing here means retrieving the saved price for each order. It does not mean calculating a new market price whenever the customer retries.

If the lookup is rejected, the pricing service returns an error. Aperture cannot proceed with that price lookup as though it succeeded.

6. The customer receives an invoice and token

Aperture uses its configured merchant LND connection to obtain a Lightning invoice and returns an L402 challenge.

The response has this general shape:

HTTP/1.1 402 Payment Required
WWW-Authenticate: L402 macaroon="<issued-token>", invoice="<lightning-invoice>"

The two values serve different purposes:

ValuePurpose
invoiceThe payment request the customer’s Lightning wallet will pay
macaroonThe authorization token submitted later with the payment proof

Our adapter extracts the invoice and macaroon. The application stores the issuance result, changes the challenge state to issued, and returns the payment instructions to the customer.

HTTP 402 is an expected response at this stage. It tells the client that payment is required before proceeding.

A client that supports this flow can present the invoice to a wallet, complete the payment, and retry with the required credentials.

What has happened so far?

Configured product price
        ↓
Saved quote
        ↓
Order created: awaiting_payment
        ↓
Payment challenge created: issuing
        ↓
Aperture requests the saved order price
        ↓
Invoice and macaroon returned
        ↓
Payment challenge updated: issued

At this point, an invoice exists, but the order is still unpaid. Issuing payment instructions is different from receiving payment.

The next part follows the customer’s payment: obtaining the preimage, retrying with L402 credentials, verifying settlement, and marking the order as paid.

Next: Lightning Payments for OpenCTI (3) - From Lightning Payment to a Paid Scan Order


Share this post:

Previous Post
Lightning Payments for OpenCTI (3) - From Lightning Payment to a Paid Scan Order
Next Post
Lightning Payments for OpenCTI (1) - Aperture as a Payment Gate