{
  "openapi": "3.0.3",
  "info": {
    "title": "Vela Pay Payment API",
    "description": "Integration guide and API reference for Vela Pay. Start with \"Getting started\", then create a payment and handle callbacks.",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.velapay.io",
      "description": "Production"
    },
    {
      "url": "https://sandbox.velapay.io",
      "description": "Sandbox"
    }
  ],
  "paths": {
    "/payment": {
      "post": {
        "summary": "Create Payment",
        "operationId": "createPayment",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePaymentRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Payment created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatePaymentResponse"
                }
              }
            }
          },
          "402": {
            "description": "VL_FUNDS_SHORT",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "VL_ACCOUNT_BARRED, VL_SECRETS_DENIED",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "404": {
            "description": "VL_MOVEMENT_MISSING",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "409": {
            "description": "VL_TOKEN_REUSED, VL_STATE_CLASH, VL_ALREADY_SETTLED",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "422": {
            "description": "VL_SUM_OUT_OF_RANGE, VL_CCY_NOT_ALLOWED, VL_RAIL_OFF, VL_CHANNEL_MISMATCH, VL_SOURCE_ACCOUNT_BAD, VL_SOURCE_NAME_BAD, VL_HOOK_BAD, VL_RETURN_BAD, VL_WIDGET_UNKNOWN, VL_TOKEN_BAD, VL_ADJUST_BAD, VL_RECALL_BAD, VL_PAYLOAD_BAD, VL_LIMIT_HIT",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "423": {
            "description": "VL_ENTRY_HELD",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "429": {
            "description": "VL_TOO_FAST, VL_VELOCITY_HIT, VL_CONCURRENCY_HIT",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "VL_CORE_BROKE, VL_STATE_BROKEN, VL_SETTLE_FAULT, VL_UPSTREAM_ODD, VL_TIMED_OUT, VL_UNSORTED_FAULT",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "VL_RAIL_SILENT",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "VL_RAIL_PAUSED, VL_OVER_CAPACITY",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/{id}/status": {
      "get": {
        "summary": "Check Payment Status",
        "operationId": "checkStatus",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Payment status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreatePaymentResponse"
                }
              }
            }
          }
        }
      }
    },
    "/confirm": {
      "post": {
        "summary": "Confirm Payment",
        "operationId": "confirmPayment",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "Payment ID"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Confirmation accepted"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "CreatePaymentRequest": {
        "type": "object",
        "properties": {
          "settle_sum": {
            "type": "string"
          },
          "settle_ccy": {
            "type": "string"
          },
          "order_token": {
            "type": "string"
          },
          "notify_hook": {
            "type": "string"
          },
          "rail_kind": {
            "type": "string",
            "enum": [
              "p2p_send",
              "card_charge",
              "chain_pay"
            ]
          },
          "rail_provider": {
            "type": "string"
          },
          "source_account": {
            "type": "string"
          },
          "source_name": {
            "type": "string"
          },
          "return_link": {
            "type": "string"
          },
          "headless_flag": {
            "type": "boolean"
          },
          "widget_kind": {
            "type": "string"
          },
          "rail_secrets": {
            "type": "string"
          },
          "flow_dir": {
            "type": "string",
            "enum": [
              "inflow",
              "remit"
            ],
            "description": "Payment direction"
          }
        }
      },
      "CreatePaymentResponse": {
        "type": "object",
        "properties": {
          "movement_id": {
            "type": "string"
          },
          "pay_link": {
            "type": "string"
          },
          "movement_state": {
            "type": "string",
            "enum": [
              "on_hold",
              "processed",
              "unsuccessful",
              "discarded",
              "sent_back",
              "contested"
            ],
            "description": "Payment status"
          },
          "settle_sum": {
            "type": "string"
          },
          "net_amount": {
            "type": "string"
          },
          "settle_ccy": {
            "type": "string"
          },
          "opened_at": {
            "type": "string"
          },
          "cleared_time": {
            "type": "string"
          },
          "rail_secrets": {
            "type": "string"
          },
          "decline_note": {
            "type": "string"
          },
          "lapse_time": {
            "type": "string"
          },
          "order_token": {
            "type": "string"
          }
        }
      },
      "StatusEnum": {
        "type": "string",
        "enum": [
          "on_hold",
          "processed",
          "unsuccessful",
          "discarded",
          "sent_back",
          "contested"
        ]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "detail": {
            "type": "string"
          }
        }
      }
    },
    "responses": {
      "Error402": {
        "description": "VL_FUNDS_SHORT: The payer does not have enough balance to complete this inflow.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_FUNDS_SHORT",
              "message": "The payer does not have enough balance to complete this inflow."
            }
          }
        }
      },
      "Error404": {
        "description": "VL_MOVEMENT_MISSING: No movement was found for the supplied reference.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_MOVEMENT_MISSING",
              "message": "No movement was found for the supplied reference."
            }
          }
        }
      },
      "Error423": {
        "description": "VL_ENTRY_HELD: This movement is held while an adjustment is being applied.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_ENTRY_HELD",
              "message": "This movement is held while an adjustment is being applied."
            }
          }
        }
      },
      "Error455": {
        "description": "VL_SUM_OUT_OF_RANGE: The settle amount is outside the range allowed for this rail.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_SUM_OUT_OF_RANGE",
              "message": "The settle amount is outside the range allowed for this rail."
            }
          }
        }
      },
      "Error457": {
        "description": "VL_TOKEN_REUSED: A movement already exists for this order token.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_TOKEN_REUSED",
              "message": "A movement already exists for this order token."
            }
          }
        }
      },
      "Error458": {
        "description": "VL_CCY_NOT_ALLOWED: This account settles in INR only.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_CCY_NOT_ALLOWED",
              "message": "This account settles in INR only."
            }
          }
        }
      },
      "Error459": {
        "description": "VL_RAIL_SILENT: The upstream rail gave no response; please retry shortly.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_RAIL_SILENT",
              "message": "The upstream rail gave no response; please retry shortly."
            }
          }
        }
      },
      "Error460": {
        "description": "VL_RAIL_OFF: The chosen rail is not enabled for this merchant.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_RAIL_OFF",
              "message": "The chosen rail is not enabled for this merchant."
            }
          }
        }
      },
      "Error461": {
        "description": "VL_RAIL_PAUSED: This rail is paused for maintenance right now.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_RAIL_PAUSED",
              "message": "This rail is paused for maintenance right now."
            }
          }
        }
      },
      "Error462": {
        "description": "VL_CHANNEL_MISMATCH: The channel is not valid for the selected rail.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_CHANNEL_MISMATCH",
              "message": "The channel is not valid for the selected rail."
            }
          }
        }
      },
      "Error464": {
        "description": "VL_ACCOUNT_BARRED: This merchant account may not transact.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_ACCOUNT_BARRED",
              "message": "This merchant account may not transact."
            }
          }
        }
      },
      "Error465": {
        "description": "VL_SOURCE_ACCOUNT_BAD: The sender account details did not pass validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_SOURCE_ACCOUNT_BAD",
              "message": "The sender account details did not pass validation."
            }
          }
        }
      },
      "Error466": {
        "description": "VL_SOURCE_NAME_BAD: The sender name is missing or malformed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_SOURCE_NAME_BAD",
              "message": "The sender name is missing or malformed."
            }
          }
        }
      },
      "Error467": {
        "description": "VL_CORE_BROKE: An internal fault occurred; the movement was not created.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_CORE_BROKE",
              "message": "An internal fault occurred; the movement was not created."
            }
          }
        }
      },
      "Error468": {
        "description": "VL_OVER_CAPACITY: The gateway is at capacity; please retry shortly.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_OVER_CAPACITY",
              "message": "The gateway is at capacity; please retry shortly."
            }
          }
        }
      },
      "Error469": {
        "description": "VL_STATE_BROKEN: The movement reached an inconsistent state and was rejected.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_STATE_BROKEN",
              "message": "The movement reached an inconsistent state and was rejected."
            }
          }
        }
      },
      "Error470": {
        "description": "VL_STATE_CLASH: The requested action conflicts with the current movement state.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_STATE_CLASH",
              "message": "The requested action conflicts with the current movement state."
            }
          }
        }
      },
      "Error471": {
        "description": "VL_TOO_FAST: Too many requests; slow down and retry.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_TOO_FAST",
              "message": "Too many requests; slow down and retry."
            }
          }
        }
      },
      "Error472": {
        "description": "VL_SECRETS_DENIED: The supplied rail secrets were rejected.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_SECRETS_DENIED",
              "message": "The supplied rail secrets were rejected."
            }
          }
        }
      },
      "Error473": {
        "description": "VL_VELOCITY_HIT: A velocity limit was hit for this account.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_VELOCITY_HIT",
              "message": "A velocity limit was hit for this account."
            }
          }
        }
      },
      "Error474": {
        "description": "VL_HOOK_BAD: The notify hook must be a public HTTPS URL.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_HOOK_BAD",
              "message": "The notify hook must be a public HTTPS URL."
            }
          }
        }
      },
      "Error475": {
        "description": "VL_RETURN_BAD: The return link is missing or invalid.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_RETURN_BAD",
              "message": "The return link is missing or invalid."
            }
          }
        }
      },
      "Error476": {
        "description": "VL_WIDGET_UNKNOWN: The requested widget kind is not recognised.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_WIDGET_UNKNOWN",
              "message": "The requested widget kind is not recognised."
            }
          }
        }
      },
      "Error477": {
        "description": "VL_CONCURRENCY_HIT: Too many concurrent operations on this movement.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_CONCURRENCY_HIT",
              "message": "Too many concurrent operations on this movement."
            }
          }
        }
      },
      "Error478": {
        "description": "VL_TOKEN_BAD: The order token is missing or malformed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_TOKEN_BAD",
              "message": "The order token is missing or malformed."
            }
          }
        }
      },
      "Error479": {
        "description": "VL_ALREADY_SETTLED: This movement has already settled.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_ALREADY_SETTLED",
              "message": "This movement has already settled."
            }
          }
        }
      },
      "Error480": {
        "description": "VL_SETTLE_FAULT: Settlement could not be recorded; support has been notified.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_SETTLE_FAULT",
              "message": "Settlement could not be recorded; support has been notified."
            }
          }
        }
      },
      "Error481": {
        "description": "VL_ADJUST_BAD: The adjustment amount is not allowed for this movement.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_ADJUST_BAD",
              "message": "The adjustment amount is not allowed for this movement."
            }
          }
        }
      },
      "Error482": {
        "description": "VL_RECALL_BAD: This movement is not eligible for a send-back.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_RECALL_BAD",
              "message": "This movement is not eligible for a send-back."
            }
          }
        }
      },
      "Error489": {
        "description": "VL_PAYLOAD_BAD: The request payload failed schema validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_PAYLOAD_BAD",
              "message": "The request payload failed schema validation."
            }
          }
        }
      },
      "Error527": {
        "description": "VL_UPSTREAM_ODD: An upstream provider returned an unexpected response.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_UPSTREAM_ODD",
              "message": "An upstream provider returned an unexpected response."
            }
          }
        }
      },
      "Error552": {
        "description": "VL_TIMED_OUT: The operation timed out before the rail confirmed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_TIMED_OUT",
              "message": "The operation timed out before the rail confirmed."
            }
          }
        }
      },
      "Error568": {
        "description": "VL_LIMIT_HIT: The amount breaches a configured movement limit.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_LIMIT_HIT",
              "message": "The amount breaches a configured movement limit."
            }
          }
        }
      },
      "Error591": {
        "description": "VL_UNSORTED_FAULT: An unclassified processing error occurred.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "VL_UNSORTED_FAULT",
              "message": "An unclassified processing error occurred."
            }
          }
        }
      }
    }
  },
  "x-topics": [
    {
      "title": "Getting started",
      "content": "## Environments\n\n| | Base URL |\n|---|---|\n| Sandbox | `https://sandbox.velapay.io` |\n| Production | `https://api.velapay.io` |\n\nCreate API keys in the merchant cabinet (`https://merchant.velapay.io`, Settings). Every request carries the key in `Authorization: Bearer <key>` or `X-Api-Key: <key>`. Requests are accepted only from the IP addresses allow-listed for the key. Keys can be rotated in the cabinet at any time; the previous key keeps working until you revoke it.\n\nAll amounts are strings with two decimals in INR. Timestamps are ISO 8601 in UTC."
    },
    {
      "title": "Create a payment",
      "content": "Call `POST /payment` with the order amount, your own order reference and the webhook URL that will receive status updates.\n\n```json\n{\n  \"settle_sum\": \"1250.00\",\n  \"settle_ccy\": \"INR\",\n  \"order_token\": \"order-2026-000123\",\n  \"notify_hook\": \"https://merchant.example/hooks/payments\",\n  \"rail_kind\": \"p2p_send\"\n}\n```\n\n- `order_token` makes the call idempotent: repeating a request with the same value returns the original payment instead of creating a second one. Use your order id.\n- `notify_hook` is required. It must be an HTTPS URL reachable from the internet.\n- The response contains `movement_id` (store it), `movement_state` and `pay_link`: redirect the payer to that page. It shows the payment details, opens the payer's UPI app and collects the confirmation. You do not need to render anything yourself."
    },
    {
      "title": "Payment lifecycle",
      "content": "A payment moves through the states below. Poll `GET /{id}/status` (no more often than every 5 seconds) or, better, rely on the callback and use polling only as a fallback.\n\n| State | Meaning | Next step |\n|---|---|---|\n| `on_hold` | awaiting the payer; the payer is completing the transfer | intermediate, keep polling or wait for the callback |\n| `processed` | awaiting the payer; funds received | intermediate, keep polling or wait for the callback |\n| `unsuccessful` | not completed | final |\n| `discarded` | not completed; the payment window closed | final |\n| `sent_back` | returned to the payer; awaiting the payer | intermediate, keep polling or wait for the callback |\n| `contested` | disputed | final |\n\nOnly final states are stable. Never treat an intermediate state as paid. `net_amount` is the amount actually received and `cleared_time` the time the funds were confirmed."
    },
    {
      "title": "Callbacks",
      "content": "Every state change is delivered with `POST` to the `notify_hook` of the payment. The body has the same shape as the status response.\n\nEach delivery carries `X-Webhook-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 of the string `<t>.<raw request body>` computed with the webhook secret issued to you at onboarding (keep it out of your client-side code). Recompute it over the raw body exactly as received, compare in constant time and reject deliveries whose `t` is older than five minutes. If no webhook secret was issued for your account, the header is absent and you must fetch the payment state with the status endpoint before acting on a callback.\n\nRespond with any 2xx status within 10 seconds. Any other response or a timeout is retried with increasing delays for about 32 hours, so your handler must be idempotent: the same event can arrive more than once. Process by `movement_id` and the state, not by delivery order."
    },
    {
      "title": "Payer confirmation and receipts",
      "content": "On the hosted payment page the payer completes the transfer in a UPI app or by bank transfer and then confirms it either with the 12-digit bank reference (UTR) or by uploading a receipt. Vela Pay matches the confirmation against the incoming funds; the payment stays in an intermediate state until the match is complete and then moves to a final state, which you receive through the callback.\n\nIf you collect the bank reference yourself, submit it with `POST /confirm` together with `movement_id`. A reference that does not match, was already used or belongs to a different amount ends the payment with a final failure state and `decline_note` explaining why; the payer is offered to upload a receipt or to contact support on the page."
    },
    {
      "title": "Errors",
      "content": "Errors are returned with an HTTP status and a stable code. Use the code, not the message, in your logic.\n\n| Code | HTTP | Message |\n|---|---|---|\n| `VL_FUNDS_SHORT` | 402 | The payer does not have enough balance to complete this inflow. |\n| `VL_MOVEMENT_MISSING` | 404 | No movement was found for the supplied reference. |\n| `VL_ENTRY_HELD` | 423 | This movement is held while an adjustment is being applied. |\n| `VL_SUM_OUT_OF_RANGE` | 422 | The settle amount is outside the range allowed for this rail. |\n| `VL_TOKEN_REUSED` | 409 | A movement already exists for this order token. |\n| `VL_CCY_NOT_ALLOWED` | 422 | This account settles in INR only. |\n| `VL_RAIL_SILENT` | 502 | The upstream rail gave no response; please retry shortly. |\n| `VL_RAIL_OFF` | 422 | The chosen rail is not enabled for this merchant. |\n| `VL_RAIL_PAUSED` | 503 | This rail is paused for maintenance right now. |\n| `VL_CHANNEL_MISMATCH` | 422 | The channel is not valid for the selected rail. |\n| `VL_ACCOUNT_BARRED` | 403 | This merchant account may not transact. |\n| `VL_SOURCE_ACCOUNT_BAD` | 422 | The sender account details did not pass validation. |\n| `VL_SOURCE_NAME_BAD` | 422 | The sender name is missing or malformed. |\n| `VL_CORE_BROKE` | 500 | An internal fault occurred; the movement was not created. |\n| `VL_OVER_CAPACITY` | 503 | The gateway is at capacity; please retry shortly. |\n| `VL_STATE_BROKEN` | 500 | The movement reached an inconsistent state and was rejected. |\n| `VL_STATE_CLASH` | 409 | The requested action conflicts with the current movement state. |\n| `VL_TOO_FAST` | 429 | Too many requests; slow down and retry. |\n| `VL_SECRETS_DENIED` | 403 | The supplied rail secrets were rejected. |\n| `VL_VELOCITY_HIT` | 429 | A velocity limit was hit for this account. |\n| `VL_HOOK_BAD` | 422 | The notify hook must be a public HTTPS URL. |\n| `VL_RETURN_BAD` | 422 | The return link is missing or invalid. |\n| `VL_WIDGET_UNKNOWN` | 422 | The requested widget kind is not recognised. |\n| `VL_CONCURRENCY_HIT` | 429 | Too many concurrent operations on this movement. |\n| `VL_TOKEN_BAD` | 422 | The order token is missing or malformed. |\n| `VL_ALREADY_SETTLED` | 409 | This movement has already settled. |\n| `VL_SETTLE_FAULT` | 500 | Settlement could not be recorded; support has been notified. |\n| `VL_ADJUST_BAD` | 422 | The adjustment amount is not allowed for this movement. |\n| `VL_RECALL_BAD` | 422 | This movement is not eligible for a send-back. |\n| `VL_PAYLOAD_BAD` | 422 | The request payload failed schema validation. |\n| `VL_UPSTREAM_ODD` | 500 | An upstream provider returned an unexpected response. |\n| `VL_TIMED_OUT` | 500 | The operation timed out before the rail confirmed. |\n| `VL_LIMIT_HIT` | 422 | The amount breaches a configured movement limit. |\n| `VL_UNSORTED_FAULT` | 500 | An unclassified processing error occurred. |\n| `VL_UNEXPECTED` | 500 | An unexpected error occurred while processing the request. |\n\nValidation problems (missing fields, wrong types) come back as 400 with a list of fields. 401 means the key or the source IP is not accepted. 5xx responses are safe to retry with the same `order_token`."
    },
    {
      "title": "Sandbox and testing",
      "content": "Use the sandbox base URL with sandbox keys from `https://sandbox-merchant.velapay.io`. Payments there never move real money: the payment page lets you complete or fail a payment on demand, so you can test every state, the callback signature and your retry handling. Check that your endpoint answers 2xx and that repeated deliveries do not create duplicate orders."
    },
    {
      "title": "Reconciliation",
      "content": "The merchant cabinet provides a settlement report (CSV or XLSX) for any period with the bank reference of every payment, gross amount, fees and net amount. Match the report against your bank statement by the bank reference. Payouts show the same breakdown per settlement."
    }
  ]
}
