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
amountRefundableper source and rejects further refunds withvalidation_erroronce 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.