Refunds

A refund moves money back to the buyer for a completed checkout session or a paid invoice. It can be full (omit Amount) or partial (specify Amount in the source currency's smallest unit). Refunds are asynchronous — the API returns immediately in pending, the underlying provider settles within minutes to days depending on the payment method, and the refund transitions to succeeded (or failed) with a webhook. The Go SDK exposes the three refund methods behind client.Refunds. For wire shapes and provider-specific timing, see API → Refunds.

Field on the Client

client.Refunds — type *plugipay.RefundsResource. Installed by NewClient; shares the parent *http.Client, base URL, key, and OnBehalfOf default. Safe for concurrent use.

Methods

Create

Signature. func (r *RefundsResource) Create(ctx context.Context, in RefundCreateInput) (*Refund, error)

Issues a refund against a SourceType + SourceID pair. Pass Amount for partial; omit for a full refund of the source's outstanding balance. Reason is free-text ("customer_request", "duplicate", "fraudulent", or anything else you log). Auto-keyed for idempotency.

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

reason := "customer_request"

// Full refund of a checkout session
ref, err := client.Refunds.Create(ctx, plugipay.RefundCreateInput{
    SourceType: plugipay.SourceTypeCheckoutSession,
    SourceID:   "cs_01HX...",
    Reason:     &reason,
})
if err != nil {
    return err
}
log.Printf("refund %s status=%s", ref.ID, ref.Status) // → "pending"

Partial refund of an invoice:

amt := int64(50_000_00) // IDR 50,000
ref, err := client.Refunds.Create(ctx, plugipay.RefundCreateInput{
    SourceType: plugipay.SourceTypeInvoice,
    SourceID:   "in_01HX...",
    Amount:     &amt,
    Reason:     &reason,
})

Total partial refunds can't exceed the source's net. The server tracks amountRefundable per source and rejects further refunds with validation_error once exhausted. There is no separate "available to refund" API call — submit and handle the error.

Get

Signature. func (r *RefundsResource) Get(ctx context.Context, id string) (*Refund, error)

Fetch one refund. Use to poll status if you can't (or don't want to) consume webhooks. Naturally idempotent.

ref, err := client.Refunds.Get(ctx, "rf_01HX...")
if err != nil {
    return err
}
switch ref.Status {
case plugipay.RefundStatusSucceeded:
    // money landed
case plugipay.RefundStatusFailed:
    log.Printf("refund failed: %s", deref(ref.FailureReason))
case plugipay.RefundStatusPending:
    // still in flight
}

List

Signature. func (r *RefundsResource) List(ctx context.Context, params RefundListParams) (Page[Refund], error)

Cursor-paginated. Filters: Status (typed *RefundStatus), SourceID (all refunds against a single session or invoice).

status := plugipay.RefundStatusSucceeded
limit := 50
page, err := client.Refunds.List(ctx, plugipay.RefundListParams{
    Status: &status,
    Limit:  &limit,
})
for _, ref := range page.Data {
    log.Printf("%s %s %d %s", ref.ID, ref.SourceType, ref.Amount, ref.Currency)
}

Or all refunds for one charge:

src := "cs_01HX..."
page, err := client.Refunds.List(ctx, plugipay.RefundListParams{SourceID: &src})

Types

type Refund struct {
    ID            string       `json:"id"`         // "rf_..."
    AccountID     string       `json:"accountId"`
    Amount        int64        `json:"amount"`
    Currency      CurrencyCode `json:"currency"`
    Status        RefundStatus `json:"status"`     // pending|succeeded|failed|canceled
    Reason        *string      `json:"reason"`
    SourceType    SourceType   `json:"sourceType"` // checkout_session | invoice
    SourceID      string       `json:"sourceId"`
    FailureReason *string      `json:"failureReason"`
    CreatedAt     string       `json:"createdAt"`
    UpdatedAt     string       `json:"updatedAt"`
}

Typed enums (use the constants in code):

plugipay.RefundStatusPending
plugipay.RefundStatusSucceeded
plugipay.RefundStatusFailed
plugipay.RefundStatusCanceled

plugipay.SourceTypeCheckoutSession
plugipay.SourceTypeInvoice

Provider-specific timing and field rules: API → Refunds.

Common patterns

Idempotent refund from your support tool

The SDK's auto-keyed idempotency mints a fresh key per call — safe for a retry of the same Go statement, but if a support agent double-clicks "Refund", you'll get two refunds. Use a stable key:

func refundFromTicket(ctx context.Context, c *plugipay.Client, ticketID string, in plugipay.RefundCreateInput) (*plugipay.Refund, error) {
    var out plugipay.Refund
    err := c.Do(ctx, plugipay.RequestOptions{
        Method:         "POST",
        Path:           "/api/v1/refunds",
        Body:           in,
        IdempotencyKey: "ticket_" + ticketID, // your stable key
    }, &out)
    if err != nil { return nil, err }
    return &out, nil
}

Polling for terminal state

Webhooks are the right answer in production. For tests:

func awaitRefund(ctx context.Context, c *plugipay.Client, id string) (*plugipay.Refund, error) {
    backoff := time.Second
    for {
        ref, err := c.Refunds.Get(ctx, id)
        if err != nil { return nil, err }
        switch ref.Status {
        case plugipay.RefundStatusSucceeded,
             plugipay.RefundStatusFailed,
             plugipay.RefundStatusCanceled:
            return ref, nil
        }
        select {
        case <-ctx.Done(): return nil, ctx.Err()
        case <-time.After(backoff):
        }
        if backoff < 30*time.Second { backoff *= 2 }
    }
}

Handling "already refunded" race

If two ops issue full refunds in parallel, one wins and the other gets validation_error with a message like "amount exceeds refundable balance". Branch on it:

ref, err := c.Refunds.Create(ctx, in)
var pe *plugipay.Error
if errors.As(err, &pe) && pe.Code == "validation_error" &&
    strings.Contains(pe.Message, "refundable") {
    // already refunded by someone else — fetch existing refunds for this source
    src := in.SourceID
    page, _ := c.Refunds.List(ctx, plugipay.RefundListParams{SourceID: &src})
    if len(page.Data) > 0 { return &page.Data[0], nil }
}

Errors-aware retry

Refund Create is auto-keyed for idempotency, so retrying 5xx is safe:

for attempt := 0; attempt < 4; attempt++ {
    ref, err = c.Refunds.Create(ctx, in)
    if err == nil { break }
    var pe *plugipay.Error
    if !errors.As(err, &pe) || (pe.Status != 0 && pe.Status < 500 && pe.Status != 429) {
        return err
    }
    time.Sleep(time.Duration(1<<attempt) * 250 * time.Millisecond)
}

Errors

Code Status Cause
validation_error 400 Amount exceeds refundable balance, bad SourceType.
not_found 404 Source id doesn't exist or isn't in a refundable state.
conflict 409 Same idempotency key with a different body.
provider_error 502 Upstream provider failed; refund stays pending — check the event log.
insufficient_scope 403 Key lacks plugipay:refund:write.

Branch with errors.As:

var pe *plugipay.Error
if errors.As(err, &pe) && pe.Code == "provider_error" {
    log.Printf("retry later: %s (requestId=%s)", pe.Message, pe.RequestID)
}

Next

  • Webhooks — subscribe to refund.succeeded / refund.failed.
  • Ledger — refunds post debit entries here.
  • API → Refunds — HTTP-level reference.
Plugipay — Payments that don't tax your success