Skip to content
API
Open .md
On this page

Create

Refund a paid or overpaid invoice in full or for the overpayment difference back to the first confirmed sender address.

POST/v1/refunds

API key: Payment

Creates an on-chain refund for a paid or paid_over invoice within 14 days of its last confirmed deposit. Both the destination address and the refund amount are computed automatically by the server: funds return to the sender address of the first confirmed invoice deposit (first_sender), and the merchant balance is debited for amount + fee.

Request body

Parameter Type Required Description
invoice_id string Yes Prefixed invoice identifier (inv_...) belonging to a project in the key's scope
mode string Yes "full" (returns the remaining refundable invoice balance) or "overpayment" (returns only the excess over the requested invoice amount)
external_order_id string Yes Your idempotency reference (max 200 characters), unique per merchant and environment

Example request

{
  "invoice_id": "inv_7K2M9P4Q8R1X5Z3A0bC2dE",
  "mode": "full",
  "external_order_id": "refund-1042-full"
}

Code examples

Each tab uses an official SDK with a Payment key (pk_). Signing, serialization, safe retries, and typed errors are handled by the SDK.

API_KEY_ID="pk_live_xxxxxxxxxxxx"
API_SECRET="sk_live_xxxxxxxxxxxx"
BODY='{"invoice_id":"inv_7K2M9P4Q8R1X5Z3A0bC2dE","mode":"full","external_order_id":"refund-1042-full"}'

TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIGNATURE=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" POST /v1/refunds '' "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$API_SECRET" -binary | base64)

curl -sS https://api.paymos.io/v1/refunds \
  -H "Authorization: HMAC-SHA256 $API_KEY_ID:$SIGNATURE" \
  -H "X-Request-Timestamp: $TS" \
  -H "Content-Type: application/json" \
  -d "$BODY"
import { Paymos, externalOrderId } from '@paymos/sdk';

const paymos = new Paymos({
  apiKey: process.env.PAYMOS_API_KEY,
  apiSecret: process.env.PAYMOS_API_SECRET,
});
const refund = await paymos.refunds.create({
  invoiceId: 'inv_7K2M9P4Q8R1X5Z3A0bC2dE',
  mode: 'full',
  externalOrderId: externalOrderId('refund'),
});

console.log(refund.withdrawalId, refund.status);
import os

from paymos import Paymos

paymos = Paymos(
    api_key=os.environ["PAYMOS_API_KEY"],
    api_secret=os.environ["PAYMOS_API_SECRET"],
)
refund = paymos.refunds.create(
    invoice_id="inv_7K2M9P4Q8R1X5Z3A0bC2dE",
    mode="full",
    external_order_id="refund-1042-full",
)

print(refund["withdrawal_id"], refund["status"])
<?php
use Paymos\Client;
use Paymos\ClientConfig;
use Paymos\IdempotencyKey;

$paymos = new Client(new ClientConfig(
    getenv('PAYMOS_API_KEY'),
    getenv('PAYMOS_API_SECRET')
));
$refund = $paymos->refunds()->create(array(
    'invoice_id' => 'inv_7K2M9P4Q8R1X5Z3A0bC2dE',
    'mode' => 'full',
    'external_order_id' => IdempotencyKey::externalOrderId('refund'),
));

echo $refund['withdrawal_id'] . ' ' . $refund['status'];
package main

import (
	"context"
	"fmt"
	"os"

	paymos "github.com/Paymos-labs/go-sdk/v2"
)

func main() {
	client, err := paymos.NewClient(os.Getenv("PAYMOS_API_KEY"), os.Getenv("PAYMOS_API_SECRET"))
	if err != nil {
		panic(err)
	}
	refund, err := client.Refunds.Create(context.Background(), paymos.CreateRefundParams{
		InvoiceID:       "inv_7K2M9P4Q8R1X5Z3A0bC2dE",
		Mode:            paymos.RefundModeFull,
		ExternalOrderID: "refund-1042-full",
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(refund.WithdrawalID, refund.Status)
}
using Paymos;

using var paymos = new PaymosClient(
    Environment.GetEnvironmentVariable("PAYMOS_API_KEY")!,
    Environment.GetEnvironmentVariable("PAYMOS_API_SECRET")!);

var refund = await paymos.Refunds.CreateAsync(new CreateRefundRequest(
    InvoiceId: "inv_7K2M9P4Q8R1X5Z3A0bC2dE",
    Mode: RefundMode.Full,
    ExternalOrderId: "refund-1042-full"));

Console.WriteLine($"{refund.WithdrawalId} {refund.Status}");
import io.paymos.CreateRefundRequest;
import io.paymos.PaymosClient;
import io.paymos.Refund;
import io.paymos.RefundMode;

PaymosClient paymos = new PaymosClient(
    System.getenv("PAYMOS_API_KEY"),
    System.getenv("PAYMOS_API_SECRET"));

Refund refund = paymos.refunds.create(new CreateRefundRequest(
    "inv_7K2M9P4Q8R1X5Z3A0bC2dE",
    RefundMode.FULL,
    "refund-1042-full"));

System.out.println(refund.withdrawalId() + " " + refund.status());
require 'paymos'

paymos = Paymos::Client.new(
  api_key: ENV.fetch('PAYMOS_API_KEY'),
  api_secret: ENV.fetch('PAYMOS_API_SECRET')
)
refund = paymos.refunds.create(
  invoice_id: 'inv_7K2M9P4Q8R1X5Z3A0bC2dE',
  mode: 'full',
  external_order_id: 'refund-1042-full'
)

puts "#{refund.withdrawal_id} #{refund.status}"
use paymos::{CreateRefundRequest, PaymosClient, RefundMode};

let paymos = PaymosClient::new(
    std::env::var("PAYMOS_API_KEY")?,
    std::env::var("PAYMOS_API_SECRET")?,
)?;
let refund = paymos
    .refunds()
    .create(&CreateRefundRequest {
        invoice_id: "inv_7K2M9P4Q8R1X5Z3A0bC2dE".to_owned(),
        mode: RefundMode::Full,
        external_order_id: "refund-1042-full".to_owned(),
    })
    .await?;

println!("{} {}", refund.withdrawal_id, refund.status);

Response (201 Created / 200 if idempotent match)

{
  "withdrawal_id": "wdr_6Qm2fK8xR4vN7cT1bY9dL5",
  "invoice_id": "inv_7K2M9P4Q8R1X5Z3A0bC2dE",
  "mode": "full",
  "external_order_id": "refund-1042-full",
  "status": "created",
  "is_final": false,
  "is_test": false,
  "amount": "100.00",
  "fee": "1.00",
  "currency": "USDT",
  "network": "TRC20",
  "destination_address": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9",
  "first_sender": "TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9",
  "created_at": 1767225600,
  "type": "refund",
  "project_id": "prj_3F9xL2mN8pQ4rT6vW1yZ5b"
}

Response fields

Field Type Description
withdrawal_id string Prefixed payout identifier (wdr_...) representing this refund
invoice_id string Prefixed invoice identifier (inv_...) being refunded
mode string Refund mode: "full" or "overpayment"
external_order_id string Your idempotency reference echoed back from the create request
status string Current status (created, signed, pending_review, cancelling, completed, failed, or cancelled)
is_final boolean true once the refund reaches a terminal state (completed, failed, cancelled)
is_test boolean true for sandbox refunds
amount string Exact token amount returned to the payer
fee string? Flat per-token network payout fee charged to the merchant balance in addition to amount. Omitted when zero
currency string Crypto asset symbol (matches the invoice's payment currency)
network string Blockchain network code (matches the invoice's payment network)
destination_address string Payout destination address (always equal to first_sender)
first_sender string Canonical sender address of the first confirmed deposit on the invoice
tx_hash string? On-chain transaction hash once status is completed
explorer_url string? Block explorer link for tx_hash once status is completed
created_at integer Unix timestamp in seconds
completed_at integer? Unix timestamp in seconds when the refund completed
failed_at integer? Unix timestamp in seconds when the refund failed
cancelled_at integer? Unix timestamp in seconds when the refund was cancelled
type string Always "refund"
project_id string Prefixed project identifier (prj_...) that owns the invoice

Idempotency

Always send a deterministic external_order_id for each refund attempt. Retrying POST /v1/refunds with the same (invoice_id, mode, external_order_id) returns the existing refund with 200 OK instead of creating a duplicate payout. Reusing an external_order_id with a different invoice_id or mode is rejected with 409 idempotency_conflict.

Only one active refund may be in flight per invoice at a time. You can issue an "overpayment" refund first and a "full" refund for the remaining principal after the overpayment refund reaches completed.

Errors

Code HTTP When
payment_key_required 403 The request was signed with a Payout (rk_) key instead of a Payment (pk_) key
refunds_not_enabled 503 Invoice refunds are disabled in this environment
invoice_not_found 404 invoice_id does not exist or is outside the API key's project scope
refund_not_eligible 409 The invoice is not in paid or paid_over status, or its deposits are not eligible
refund_no_overpayment 409 mode is "overpayment" and the invoice has no remaining overpayment balance
refund_sender_unknown 409 The sender address of the first confirmed deposit is unavailable
refund_window_expired 409 More than 14 days have passed since the invoice's last confirmed deposit
refund_network_unsupported 409 Payouts are not supported on the invoice's payment network
refund_in_flight 409 Another refund for this invoice is still in a non-terminal status
invoice_already_refunded 409 The invoice has already been refunded in full
refund_source_inconsistent 409 Invoice deposit or refund ledger history failed consistency verification
insufficient_balance 409 Merchant balance cannot cover amount + fee

See Error Codes for the full catalogue.