External purchase records

Resolve externally mapped Pricings before reconciling an external purchase with Wallkit. An external Pricing identifier selects a catalog choice; it is not a transaction ID or a member’s membership ID.

TaskOperation
Resolve a mapped PricingGET external subscription
Inspect a recorded external transactionGET external transaction
Record an external purchase for a userPOST external subscriptions
Change an existing external membershipPUT external subscriptions
Apply configured membership downgradeDELETE external subscription
Record an external refund and apply membership handlingPUT external refund

Example clients and synthetic values follow response conventions. Use WALLKIT_API_BASE for the supplied scheme and host, without /api/v1. Examples include the full path; Node.js examples use an ES module (.mjs).

Shared service-account context

Use the supplied resource public key in resource and the existing resource-scoped key in service-api-key. The account must be active, not suspended and attached to an active service-account user. Route permission and account permission are separate: the resolved service_account role must permit the action, and the account needs full_access or a grant for that exact controller/action. An ordinary member token is insufficient. Root is a separate route-ACL bypass, not the example credential.

Initialization can create/update a session, increment request counters, extend expiry and record the request/response, including for GET. An invalid service key can fall back to ordinary context resolution; no dedicated invalid-key error is guaranteed. See credential transport.

Every action below also requires an active resolved account user and valid resource; an attached resource relationship, when present, must not be locked or suspended. Each action’s request table names its grant and selectors. User-targeted mutations have additional target-user and record checks described beside the operation.

Resolve an externally mapped Pricing

GET /api/v1/integrations/external/subscription

Resolve a complete token in a Pricing’s comma-separated external_source_ids within the selected resource. The API retains subscription naming; the returned subscription is a Pricing, not a membership. No purchase or provider payment is performed.

Before you call

Use a configured external-source identifier already mapped to a Pricing in the selected resource. It is compared case-insensitively after trimming, following a case-insensitive substring prefilter; the final comparison is a complete comma-separated token. Matching does not require active or public Pricing status, user ownership or a paid membership. Multiple mapped matches have no unique-selection or ordering guarantee; a later match can replace an earlier one.

Use the shared service-account context and the grant for this exact action.

This controller also requires an active resolved user and valid resource. A locked/suspended attached resource relationship, when present, fails the shared user checks.

Request

Bodyless GET.

NameLocationType / requirementMeaning and constraints
resourceheaderrequired public-key stringSelects the resource containing the mapped Pricing.
service-api-keyheaderexisting authorized key stringRequires full access or this external lookup action grant.
external_source_idqueryrequired nonempty stringString/trim/strip-tags sanitized; then lowercased and trimmed for matching. Compares one token of external_source_ids. It is not a Wallkit Pricing integer or external transaction ID.

Examples use the safely encoded synthetic token catalog-reader-monthly. No provider/source type selector is required by this action.

Result

HTTP 200 returns subscription as the general Pricing projection, including its compact plan and conditional administrative fields.

Field / projectionType / presenceOperation-specific meaning
subscriptionobject; presentGeneral Pricing in this resource; no membership wrapper. All Pricing and plan fields are defined by the linked projection.
subscription.idintegerWallkit Pricing ID for later selection; not an external membership ID.
subscription.external_source_idsstored string / nullableComplete comma-separated configured mapping, not just the requested token.
subscription.next_subscription, subscription.downgrade_subscriptionnull; presentRelated Pricing expansion is disabled in this response, even when relations are configured.
subscription.publicbooleanInverse of private, independent of active. Private/inactive choices can be returned.
subscription.created_at, subscription.updated_at, subscription.country_vatconditional admin fieldsIncluded only when the resolved context satisfies the shared admin check; ordinary service-account access alone does not imply this.

Stored price units, currency normalization, timestamp timezone and a complete type enum are unspecified. Reading the Pricing does not grant entitlement or collect money.

Example: Resolve a catalog mapping

Resolve catalog-reader-monthly in the supplied resource and retain Pricing ID 3001. This excerpt selects fields from the full projection, showing the distinction between the external token and Wallkit ID.

curl "${WALLKIT_API_BASE}/api/v1/integrations/external/subscription?external_source_id=catalog-reader-monthly" \
  -H "resource: ${RESOURCE_KEY}" \
  -H "service-api-key: ${SERVICE_API_KEY}"
const response = await fetch(`${process.env.WALLKIT_API_BASE}/api/v1/integrations/external/subscription?external_source_id=catalog-reader-monthly`, {
  method: "GET",
  headers: {"resource": process.env.RESOURCE_KEY, "service-api-key": process.env.SERVICE_API_KEY}
});
console.log(response.status, await response.json());
import os, json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

request = Request(os.environ["WALLKIT_API_BASE"] + "/api/v1/integrations/external/subscription?external_source_id=catalog-reader-monthly",
    headers={'resource': os.environ["RESOURCE_KEY"], 'service-api-key': os.environ["SERVICE_API_KEY"]}, method="GET")
try:
    with urlopen(request) as response:
        print(response.status, json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))

Synthetic HTTP 200 excerpt:

{
  "subscription": {
    "id": 3001,
    "plan_id": 4001,
    "title": "Reader monthly",
    "external_source_ids": "catalog-reader-monthly,legacy-reader",
    "active": true,
    "private": false,
    "public": true,
    "next_subscription": null,
    "downgrade_subscription": null,
    "plan": {
      "id": 4001,
      "title": "Reader plan",
      "full_access": false
    }
  }
}

Consequential alternate

No complete mapped token in the selected resource returns HTTP 404, rather than subscription:null:

{
  "error": "subscription_not_found",
  "error_description": "Subscription not found"
}

A substring alone does not match. Conversely, multiple complete mappings do not produce an ambiguity error: do not rely on a stable winner.

Recovery

HTTP statusAPI code / shapeCauseNext action
401 / 403accessRole or service-account action permission is insufficient.Ask the integration owner to check the selected resource, account and exact grant.
401token_expired / token_compromisedResolved non-guest session failed shared session checks.Confirm the existing authorized context and attached account user.
404resource_not_existsResource key does not resolve.Correct the resource context.
401auth_failed / auth_access_failNo resolved user, inactive user, or attached resource relationship locked/suspended.Ask the integration owner to inspect the attached account user and relationship.
409external_source_id_requiredQuery value is absent or falsey.Supply a nonempty mapped identifier, including URL encoding as needed.
409subscription_source_id_requiredSanitized helper identifier is empty.Correct the token after trimming/sanitization.
404subscription_not_found / not_foundNo valid mapped Pricing, or defensive missing-result check.Confirm the complete mapping and selected resource.
400external_subscription_failedOther caught lookup failure; literal description is Create external subscription has failed.Treat it as lookup failure despite the creation wording; ask the integration owner to inspect context and mapping.

Next task

Retain the returned Pricing ID and resolve duplicate mappings with the integration owner before using it for an external membership task. See memberships for existing relationship meanings and User subscriptions and Pricing selection for separate payment and account results.

Inspect a recorded external transaction

GET /api/v1/integrations/external/transaction

Find the first stored external transaction matching its external ID in the selected resource. This lookup has no target-user parameter or user ownership filter. It does not ask a payment provider for current settlement or refund status.

Before you call

Use the shared service-account context and the grant for this exact action.

An active resolved account user and valid resource are required; an attached resource relationship, when present, must not be locked or suspended.

Use the externally recorded transaction identifier, not the catalog mapping token or local transaction ID. Duplicate external IDs have no stable selection/order guarantee.

Request

Bodyless GET.

NameLocationType / requirementMeaning and constraints
resourceheaderrequired public-key stringScopes the recorded transaction.
service-api-keyheaderexisting authorized keyRequires full access or the transaction lookup action grant.
external_source_idqueryrequired nonempty stringString/trim/strip-tags sanitized; exact stored transaction_id match, with is_external true and resource match. No lowercase/comma-token rule from Pricing lookup.

Result

HTTP 200 returns transaction with these base fields. It omits external transaction_id, data, json_data, purchases, timestamps, custom, is_external and expanded user/card/refund/receipt fields.

FieldType / presenceMeaning
transactionobject; presentSelected stored transaction, not a provider response.
transaction.idintegerLocal Wallkit transaction ID, distinct from the query external ID.
transaction.status, transaction.errorstored string / nullableStored outcome/error; external creation records succeeded without collecting money. No complete status enum.
transaction.user_id, transaction.user_card_id, transaction.payment_method_idstored numeric / nullableTarget user and local saved-source IDs. No full user/card objects.
transaction.pay_system, transaction.currencystored strings / nullableStored operator label and currency. External creation uses in-app; not proof of an in-app provider call.
transaction.amount, transaction.amount_refunded, transaction.feeintegersStored amount/refunded amount/fee; scale unspecified.
transaction.connect_fee, transaction.processing_fee_wallkit, transaction.processing_fee_payment, transaction.processing_vatstored numeric / nullableRecorded fee/VAT values, not calculated by this lookup.
transaction.livemodebooleanRecorded mode supplied when recording; not evidence of a live provider verification.

A succeeded status is a local recorded value. This read does not prove membership, provider completion or money returned.

Example: Resolve the local transaction ID

Look up order-reader-1001 recorded by the external system. Retain the local ID separately; the query identifier is not echoed in this response.

curl "${WALLKIT_API_BASE}/api/v1/integrations/external/transaction?external_source_id=order-reader-1001" \
  -H "resource: ${RESOURCE_KEY}" \
  -H "service-api-key: ${SERVICE_API_KEY}"
const response = await fetch(`${process.env.WALLKIT_API_BASE}/api/v1/integrations/external/transaction?external_source_id=order-reader-1001`, {
  method: "GET",
  headers: {"resource": process.env.RESOURCE_KEY, "service-api-key": process.env.SERVICE_API_KEY}
});
console.log(response.status, await response.json());
import os, json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

request = Request(os.environ["WALLKIT_API_BASE"] + "/api/v1/integrations/external/transaction?external_source_id=order-reader-1001",
    headers={'resource': os.environ["RESOURCE_KEY"], 'service-api-key': os.environ["SERVICE_API_KEY"]}, method="GET")
try:
    with urlopen(request) as response:
        print(response.status, json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))

Synthetic HTTP 200 excerpt:

{
  "transaction": {
    "id": 5001,
    "status": "succeeded",
    "user_id": 1001,
    "pay_system": "in-app",
    "amount": 1000,
    "currency": "USD",
    "amount_refunded": 0,
    "livemode": false
  }
}

Consequential alternate

No external transaction with that exact ID in the resource returns HTTP 404:

{
  "error": "get_transaction_error",
  "error_description": "Transaction not found"
}

An ordinary nonexternal transaction does not match. A transaction for a different user in this resource can match, because this operation does not filter by target user.

Recovery

HTTP statusAPI code / shapeCauseNext action
401 / 403accessResolved context or account lacks this action.Ask the integration owner to check the resource/key and exact account grant.
401auth_failed / auth_access_fail / token_expired / token_compromisedUser/session or attached relationship failed shared checks.Confirm the existing authorized account context.
404resource_not_existsInvalid resource key.Correct the resource selection.
409external_source_id_requiredQuery value absent or falsey.Supply the stored external transaction ID, with URL encoding.
404get_transaction_errorNo matching external transaction in this resource.Confirm the identifier and existing recording; do not create a duplicate blindly.
409 / 400get_transaction_errorCaught helper/controller lookup failure.Retain the error and ask the integration owner to inspect it.

Next task

Compare transaction.user_id with the intended user and keep local/external IDs separate. Before recording an external purchase, resolve existing duplicates or uncertain prior results; lookup alone is not an idempotency key.

Record an external purchase for a user

POST /api/v1/integrations/users/{user_id}/external/subscriptions

Record an external membership, transaction and linked purchase for a user already related to the resource. Payment must be handled separately by the external system: this call records local succeeded/in-app data and does not collect money. It can replace memberships and change associated history, sponsorship and content-view records.

Before you call

Use the shared service-account context and the grant for this exact action.

An active resolved account user and valid resource are required; an attached resource relationship, when present, must not be locked or suspended.

The target user must exist globally and have a relationship with this resource; this target lookup does not itself check target activation, lock or suspension. Select a resource-owned Pricing by integer id or a complete mapped source_id. The first existing membership found in the resource blocks a selected lower-priced Pricing with Current subscription is Higher-Tier Price; this is not a comparison across all memberships.

Supply subscription and transaction as JSON objects. Malformed/null/scalar objects can fail before their validators, without a dependable action-specific HTTP error. Resolve external ID duplication and prior uncertain results before recording.

Request

JSON body with Content-Type: application/json.

NameLocationType / requirementMeaning and constraints
resource, service-api-keyheadersrequired existing resource/account stringsRequires full access or the creation action grant.
user_idpathrequired digits / integerTarget Wallkit user ID, with existing resource relationship.
subscriptionJSONrequired nonempty objectSelects the Pricing and membership inputs below.
subscription.idJSONnonzero integer; id or source_id neededWallkit Pricing ID in this resource. A nonempty id takes precedence over source_id, and a string ID fails integer validation.
subscription.source_idJSONnonempty string; alternative to idComplete mapped external catalog token; matching follows Pricing lookup.
subscription.end_dateJSONrequired date stringParsed using Y-m-d and stored as Y-m-d; parse failure is rejected, but strict calendar-date validation and timezone are not guaranteed.
subscription.is_trialJSONoptional Boolean-filter value; default falseExisting external membership receives this flag; new membership applies it only if trial eligibility allows.
transactionJSONrequired objectSupply it even when amount/currency should default; malformed shapes have no dependable error envelope.
transaction.idJSONrequired string, trimmed; nonempty, at most 128 charactersExternal transaction identifier, not a Pricing or local transaction ID. Duplicate rejection is not implemented in this helper.
transaction.is_live_modeJSONrequired property, Boolean-filteredRecords livemode. Null/false is not omission; no provider verification.
transaction.amountJSONoptional property; integer-filtered >= 0 when presentStored amount; units unspecified. If the property is absent, both amount and currency are set from the selected Pricing. Null/present does not select that default branch.
transaction.currencyJSONrequired when amount property exists; nonempty trimmed string <= 3 charactersNo uppercase/ISO validation or conversion. When amount is omitted, any supplied currency is overwritten by Pricing currency.
transaction.dataJSONrequired property; string/trim filteredExternal descriptive data; no portable object schema. Example supplies a string.

Extra fields do not configure a provider, add a payment source or select a different target user. Subscription end_date and update’s later subscription_end_date are distinct inputs.

Result

Intended HTTP 201 returns success:true only; no membership, transaction or purchase IDs are returned.

FieldType / presenceMeaning
successboolean; intended success resultAcknowledgement after the action’s recording path; not independent payment, delivery, atomicity or access proof.

Membership and recording effects

  • An existing external membership for this user/Pricing is updated with the supplied end date and trial flag. This branch retains its existing autorenew value.
  • Otherwise, the membership helper requires a Pricing period and can enforce configured once-use history. It replaces the existing membership for that Plan, records history, and creates an external membership with autorenew forced true. Requested trial status is subject to configured trial eligibility and prior/current trial history.
  • With single_subscription enabled, other memberships in this resource can also be moved to history and deleted. Replacement/deletion can expire sponsored gifts or clear assigned multi-seat slots. Membership saves invalidate caches; deletion with auto_clear_content_views can clear the user’s content-view records without a resource predicate.
  • The actor user is recorded as admin_id. The action then creates an external/custom transaction with status succeeded and pay_system in-app, plus a purchase whose item IDs select the Pricing and whose relation ID selects the membership. These are local records; no payment-source/provider charge is performed.
  • The new-membership path attempts subscription/admin events and, with configured Campaign Monitor OAuth, synchronization before the outer action commit. The final external-recording event follows the outer commit. These attempts do not prove notification or queue delivery.

The helper uses a nested transaction and several saves are unchecked. A failure or success response cannot establish an all-or-nothing result. Missing/malformed transaction objects can fail after membership work. Duplicate transaction IDs are not rejected here; repeated POSTs can add transactions/purchases and repeat effects. Do not retry an uncertain result blindly.

Example: Record a separately completed external purchase

Record the external purchase order-reader-1001 for user 1001 using Pricing 3001, with a known end date and explicit stored amount/currency. 1000 is an illustrative stored amount, not a claim about cents. The supplied mode is synthetic false.

curl -X POST "${WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions" \
  -H "resource: ${RESOURCE_KEY}" \
  -H "service-api-key: ${SERVICE_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-raw '{"subscription": {"id": 3001, "end_date": "2026-11-01", "is_trial": false}, "transaction": {"id": "order-reader-1001", "is_live_mode": false, "amount": 1000, "currency": "USD", "data": "External purchase recorded by the integration"}}'
const response = await fetch(`${process.env.WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions`, {
  method: "POST",
  headers: {"resource": process.env.RESOURCE_KEY, "service-api-key": process.env.SERVICE_API_KEY, "Content-Type": "application/json"},
  body: JSON.stringify({"subscription": {"id": 3001, "end_date": "2026-11-01", "is_trial": false}, "transaction": {"id": "order-reader-1001", "is_live_mode": false, "amount": 1000, "currency": "USD", "data": "External purchase recorded by the integration"}})
});
console.log(response.status, await response.json());
import os, json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

body = {'subscription': {'id': 3001, 'end_date': '2026-11-01', 'is_trial': False}, 'transaction': {'id': 'order-reader-1001', 'is_live_mode': False, 'amount': 1000, 'currency': 'USD', 'data': 'External purchase recorded by the integration'}}
request = Request(os.environ["WALLKIT_API_BASE"] + "/api/v1/integrations/users/1001/external/subscriptions",
    data=json.dumps(body).encode("utf-8"),
    headers={'resource': os.environ["RESOURCE_KEY"], 'service-api-key': os.environ["SERVICE_API_KEY"], 'Content-Type': "application/json"}, method="POST")
try:
    with urlopen(request) as response:
        print(response.status, json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))

Synthetic intended HTTP 201 result:

{
  "success": true
}

This acknowledgement does not include IDs; inspect separately recorded transaction and membership state before deciding what the application should do.

Consequential alternate

If the first selected existing resource membership costs more than Pricing 3001, the action can return HTTP 409:

{
  "error": "external_purchase_error",
  "error_description": "Current subscription is Higher-Tier Price"
}

If transaction.amount is omitted from a valid transaction object, both its amount and currency are taken from Pricing 3001, even if a currency property was supplied. That changes the recorded economics; use explicit values when recording the external system’s actual amount. An uncertain error after membership work must not be interpreted as no changes.

Recovery

HTTP statusAPI code / shapeCauseNext action
401 / 403access / auth_failed / auth_access_fail / token_expired / token_compromisedAccount grant or active resolved context failed.Ask the integration owner to check account, action and resource.
404resource_not_existsResource key invalid.Correct the selected resource.
404 / 409user_not_found / user_id_requiredTarget absent, unrelated to resource or invalid integer ID.Confirm the target user ID and resource relationship.
409incorrect_dataFalsey parsed JSON.Send valid object-shaped JSON with both nested objects.
404 / 409subscription_obj_required / subscription_data_empty / subscription_id_requiredMissing selector object, missing selector or noninteger ID.Supply an integer resource-owned Pricing id or valid source_id.
404 / 409subscription_not_found / subscription_not_validPricing missing, without Plan or outside resource.Resolve the mapping and resource before recording.
409subscription_end_date_property_required / subscription_end_date_formatMissing end_date or failed Y-m-d parse.Supply the intended calendar date; do not rely on permissive normalization.
409external_purchase_errorHigher-price restriction or caught membership/transaction/purchase helper failure.Inspect existing membership and partial records before changing input or attempting another write.
409transaction_*_property_requiredRequired transaction property absent after the controller’s default branch.Supply id, is_live_mode, data and amount/currency as required above.
409transaction_id_required / transaction_id_length / transaction_amount / transaction_currency_required / transaction_currency_lengthEmpty/overlong ID, negative filtered amount, empty/overlong currency.Correct those fields; inspect prior effects because validation follows membership work.
400external_subscription_failedOther caught controller failure, possibly after writes or commit.Retain the external ID and inspect existing records with the integration owner; do not blind retry.
Unspecified outward failureMalformed nested objectMalformed-object failure can fall outside action Exception handling.Supply proper objects and treat outcome as uncertain; inspect records before another write.

Next task

Use external transaction lookup to inspect the recorded transaction and memberships for the user’s existing state. Obtain a separate content-access decision before serving protected content. No local record proves provider money movement.

Change an existing external membership

PUT /api/v1/integrations/users/{user_id}/external/subscriptions

Change trial, renewal and/or stored end-date fields on the first existing external membership for the target user and selected Pricing. This call does not create a membership, transaction or purchase, and does not change a provider’s renewal agreement.

Before you call

Use the shared service-account context and the grant for this exact action.

This controller requires an active resolved account user and valid resource; an attached resource relationship, when present, must not be locked or suspended. The target user must exist and have a relationship with this resource. Target activation/lock/suspension is not checked by the target lookup.

Select a Pricing already linked to this user by an is_external membership. A nonempty subscription.id takes precedence over source_id. It must be a nonzero JSON integer selecting a Pricing with a Plan in this resource; a string ID is rejected. Otherwise use a complete mapped subscription.source_id following Pricing lookup. No active/public Pricing condition is added.

For boolean changes, the handler compares the filtered input with the stored value, including its type. If they differ, it flips the stored flag instead of assigning the input directly. Stored types can therefore affect repeated requests. Inspect the resulting membership state before repeating a request.

Request

JSON body with Content-Type: application/json.

NameLocationType / requirementMeaning and constraints
resource, service-api-keyheadersrequired existing stringsRequires full access or this update action grant.
user_idpathrequired digits / integerTarget user with this resource relationship.
subscriptionJSONrequired nonempty objectSelects an existing external membership by its Pricing. Malformed scalar/array shapes have no dependable action error.
subscription.idJSONnonzero integer; id or source_id neededWallkit Pricing ID, not membership ID; nonempty id takes precedence.
subscription.source_idJSONnonempty string; alternative selectorExternal catalog mapping token, not transaction ID.
subscription.is_trialJSONoptional Boolean-filter inputIf present and strictly unequal to stored is_trial, invert stored flag. No trial-eligibility check in this update.
subscription.is_autorenewJSONoptional Boolean-filter inputIf present and strictly unequal to stored autorenew, invert stored flag. Does not change provider billing.
subscription.subscription_end_dateJSONoptional string-filtered valueStored directly after string filtering; no create-style Y-m-d validation or guaranteed normalization/timezone.

Omitted fields remain unchanged. Null/false present values still enter their property branches. Boolean filtering first lowercases and trims sanitized text: falsey values and the literal string "false" map to false; other nonempty text maps to true. Use actual JSON booleans. The create-only end_date key is not used here. A selector-only object still reaches save/event handling.

Result

Intended HTTP 200 result:

FieldType / presenceMeaning
successboolean; intended resultAcknowledges the update path; no changed fields or membership ID are returned.

The selected membership is saved, timestamps/cache invalidation follow model hooks, and an external_user_subscription_update event is attempted with change information. Save return is not checked. Event handling follows the save; a later error does not prove the update was undone. No explicit action transaction, local transaction/purchase creation, membership replacement, provider renewal change or payment action is performed.

Example: Request a renewal preference and end date

For user 1001 and Pricing 3001, request the local renewal flag false and store a known end date. The result does not echo those values; confirm the existing membership separately.

curl -X PUT "${WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions" \
  -H "resource: ${RESOURCE_KEY}" \
  -H "service-api-key: ${SERVICE_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-raw '{"subscription": {"id": 3001, "is_autorenew": false, "subscription_end_date": "2026-11-01"}}'
const response = await fetch(`${process.env.WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions`, {
  method: "PUT",
  headers: {"resource": process.env.RESOURCE_KEY, "service-api-key": process.env.SERVICE_API_KEY, "Content-Type": "application/json"},
  body: JSON.stringify({"subscription": {"id": 3001, "is_autorenew": false, "subscription_end_date": "2026-11-01"}})
});
console.log(response.status, await response.json());
import os, json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

body = {'subscription': {'id': 3001, 'is_autorenew': False, 'subscription_end_date': '2026-11-01'}}
request = Request(os.environ["WALLKIT_API_BASE"] + "/api/v1/integrations/users/1001/external/subscriptions",
    data=json.dumps(body).encode("utf-8"),
    headers={'resource': os.environ["RESOURCE_KEY"], 'service-api-key': os.environ["SERVICE_API_KEY"], 'Content-Type': "application/json"}, method="PUT")
try:
    with urlopen(request) as response:
        print(response.status, json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))

Synthetic intended HTTP 200 result:

{
  "success": true
}

Consequential alternate

A Pricing can exist in the resource while the target user has no external membership for it. The intended caught response is HTTP 400:

{
  "error": "external_subscription_failed",
  "error_description": "Update external subscription has failed"
}

This call does not create the missing membership. Repeating a boolean request is not a promised idempotent operation because the strict comparison and stored-type inversion govern the update.

Recovery

HTTP statusAPI code / shapeCauseNext action
401 / 403access / auth_failed / auth_access_fail / token_expired / token_compromisedAccount action grant or resolved user/session/relationship checks failed.Ask the integration owner to check the exact account, action and resource context.
404resource_not_existsResource key does not resolve.Correct the resource context.
404 / 409user_not_found / user_id_requiredTarget absent, unrelated to resource or invalid integer ID.Confirm target ID and resource relationship.
404 / 409subscription_obj_required / subscription_data_empty / subscription_id_requiredMissing object/selector or noninteger Pricing ID.Supply an object with the intended integer Pricing id or mapped source_id.
404 / 409subscription_not_found / subscription_not_validSelected Pricing absent, without Plan or outside resource.Confirm the Pricing and selected resource.
409incorrect_dataFalsey parsed JSON.Supply object-shaped JSON with subscription.
400external_subscription_failedMissing external membership or other caught update/save/event failure.Inspect current membership before another write; use the separate recording task only when an external purchase should be recorded.
Unspecified outward failureMalformed nested objectFailure can be outside Exception handling.Supply the required object shape and inspect uncertain prior effects.

Next task

Read existing memberships to confirm local fields, then apply the separate content-access decision when serving content. Provider billing and refund handling remain separate jobs.

Apply configured external membership downgrade

DELETE /api/v1/integrations/users/{user_id}/external/subscriptions/{subscription_id}

Apply Wallkit’s configured downgrade handling to an existing external membership. The subscription_id path value is the Wallkit Pricing ID, not the membership relationship ID. This is not a provider refund or a guaranteed delete-only result.

Before you call

Use the shared service-account context and the grant for this exact action.

This controller requires an active resolved account user and valid resource; an attached resource relationship, when present, must not be locked or suspended. The target user must exist and have a relationship with this resource. Target activation/lock/suspension is not checked by the target lookup.

The Pricing must belong to this resource, and the target user must have an existing is_external membership for it. Use a Pricing ID retained from lookup or existing membership data. No end-date expiry condition is required by this route. Ask the integration owner which Pricing/resource fallback is configured before requesting this transition.

Request

Bodyless DELETE; no JSON selector is used.

NameLocationType / requirementMeaning and constraints
resource, service-api-keyheadersrequired existing stringsRequires full access or this downgrade action grant.
user_idpathrequired digits / integerTarget user with this resource relationship.
subscription_idpathrequired digits / nonzero integerPricing ID in this resource, selecting an external membership for this user. Not membership, transaction or provider ID.

Result

Intended HTTP 200 returns success:true only.

FieldType / presenceMeaning
successboolean; intended resultAcknowledges invocation of the downgrade path; does not report which replacement or deletion occurred.

The configured Pricing downgrade is attempted first. If no replacement is recognized, the resource setting named downgrade_subscription_id is tried as the fallback. If neither replacement is recognized, the old membership is stored in history. The handler then attempts to delete the old membership; it does not use the next-Pricing branch for this route.

Replacement uses the membership helper and can change other memberships under single_subscription, record history, apply normal renewal defaults, stop sponsored gifts or clear multi-seat slots, invalidate caches and clear user content-view records when auto_clear_content_views is enabled. The replacement is not explicitly marked external, and its ID is not returned. Downgrade/sponsor/synchronization events may be attempted. Inner migration and handler exceptions are logged/swallowed, and a failed delete is logged. success:true therefore does not certify removal, replacement, exact resulting entitlement or notification delivery.

No explicit outer action transaction or provider money movement is performed. A partial transition is possible; repeated calls may find the original external membership missing or may repeat effects if it remains.

Example: Apply the configured transition

Request downgrade handling for user 1001’s external membership selected by Pricing 3001. Interpret the acknowledgement as a reason to inspect the resulting memberships, not as proof that all access was removed.

curl -X DELETE "${WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions/3001" \
  -H "resource: ${RESOURCE_KEY}" \
  -H "service-api-key: ${SERVICE_API_KEY}"
const response = await fetch(`${process.env.WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions/3001`, {
  method: "DELETE",
  headers: {"resource": process.env.RESOURCE_KEY, "service-api-key": process.env.SERVICE_API_KEY}
});
console.log(response.status, await response.json());
import os, json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

request = Request(os.environ["WALLKIT_API_BASE"] + "/api/v1/integrations/users/1001/external/subscriptions/3001",
    headers={'resource': os.environ["RESOURCE_KEY"], 'service-api-key': os.environ["SERVICE_API_KEY"]}, method="DELETE")
try:
    with urlopen(request) as response:
        print(response.status, json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))

Synthetic intended HTTP 200 result:

{
  "success": true
}

Consequential alternate

If the Pricing exists but the user has no external membership for it, the intended caught response is HTTP 400:

{
  "error": "external_subscription_failed",
  "error_description": "Downgrade external subscription has failed"
}

Conversely, a recognized external membership can return success:true even when an inner transition fails or no replacement is applied. A configured replacement can leave the user with a different membership; DELETE does not mean no memberships remain.

Recovery

HTTP statusAPI code / shapeCauseNext action
401 / 403access / auth_failed / auth_access_fail / token_expired / token_compromisedAccount action grant or resolved user/session/relationship checks failed.Ask the integration owner to check the exact account, action and resource context.
404resource_not_existsResource key does not resolve.Correct the resource context.
404 / 409user_not_found / user_id_requiredTarget absent, unrelated to resource or invalid integer ID.Confirm target ID and resource relationship.
404 / 409subscription_not_found / subscription_not_valid / subscription_id_requiredPricing missing, outside resource, without Plan or invalid ID.Confirm the Wallkit Pricing ID, not a membership relationship ID.
400external_subscription_failedExternal membership missing or caught outer helper/event failure.Inspect existing membership/history and configured fallback before another request.
200success:trueHandler invoked; inner exceptions/delete failure may be logged.Inspect resulting memberships and access; do not certify removal from the acknowledgement.

Next task

Inspect existing memberships and obtain a separate content-access decision. If money was refunded externally, record it through external refund recording as a separate task.

Record an external refund and handle linked memberships

PUT /api/v1/integrations/users/{user_id}/external/subscriptions/refund

Record a refund amount on an existing external transaction and invoke downgrade handling for qualifying linked memberships. The external system must handle money separately: this route makes no provider refund call and does not prove money returned. Even a partial recorded refund can trigger membership downgrade.

Before you call

Use the shared service-account context and the grant for this exact action.

This controller requires an active resolved account user and valid resource; an attached resource relationship, when present, must not be locked or suspended. The target user must exist and have a relationship with this resource. Target activation/lock/suspension is not checked by the target lookup.

Supply the external transaction ID for a stored is_external transaction matching this target user and resource. This is stricter than resource-only transaction lookup. Associated purchase relationships and an optional external catalog token can select memberships for downgrade; inspect them before recording.

Request

JSON body with Content-Type: application/json.

NameLocationType / requirementMeaning and constraints
resource, service-api-keyheadersrequired existing stringsRequires full access or this refund action grant.
user_idpathrequired digits / integerTarget user with this resource relationship.
transactionJSONrequired nonempty objectSelects the existing transaction and recorded refund amount.
transaction.idJSONrequired property, string/trim filtered, nonemptyExternal transaction_id; matching includes target user, resource and is_external true. Not local transaction.id. No 128-character guard is added here.
transaction.refund_amountJSONrequired property, integer-filteredOverwrites amount_refunded. A falsey filtered amount, including 0, records the full stored transaction amount. No explicit negative/over-amount guard and no monetary-unit promise.
transaction.external_subscription_source_idJSONoptional present string/trim-filtered propertyA complete external catalog mapping token can select another membership for downgrade. It is not a provider membership or external transaction ID.

Omitting refund_amount fails; supplying 0 does not record zero refund. Null/present enters the filter branch. This is an overwrite, not an increment or cumulative-delta calculation. Supply the intended already-completed external amount; do not use negative or over-amount values merely because no guard is visible.

Result

Intended HTTP 200 returns success:true only.

FieldType / presenceMeaning
successboolean; intended resultAcknowledges the local path; no transaction/refund/membership ID or amount is returned.

The transaction amount_refunded is overwritten and saved; status is not changed to a refunded enum, and no provider refund record is created here. The transaction model updates timestamp/casts and may adjust fee fields under its normal save rules.

For each linked purchase whose relation_model is user_plan_relationships and relation_id is nonempty, the existing relationship must exist and its user_id must strictly match the target user. Its Pricing ID is then passed to the external membership downgrade helper, which selects a first external user/Pricing relationship; this does not guarantee deletion of the purchase’s exact relation_id. Inapplicable purchase links are skipped. A selected nonexternal/missing external membership can instead fail later; recording the refund amount comes first.

When external_subscription_source_id is present, its mapped Pricing is resolved in the resource, and a user/Pricing membership is looked up before external-only downgrade. This lookup itself does not require is_external; the following downgrade does. Missing optional membership is skipped, while invalid mapping or later failures can produce an error. The same membership can be reached more than once; no deduplication is promised.

Downgrade handling keeps the replacement/history/delete and swallowed-failure limits. Amount 500 does not limit membership changes to a partial entitlement adjustment. The outer action begins a transaction, commits before its final external refund event, and can enter nested membership transactions/events. Saves are not all checked; no atomicity, delivery or replay guarantee is established.

Example: Record a partial external refund

Record an external refund amount of 500 for order-reader-1001 and user 1001. The amount is a synthetic stored value with unspecified scale. Linked membership handling may still invoke the configured downgrade.

curl -X PUT "${WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions/refund" \
  -H "resource: ${RESOURCE_KEY}" \
  -H "service-api-key: ${SERVICE_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-raw '{"transaction": {"id": "order-reader-1001", "refund_amount": 500}}'
const response = await fetch(`${process.env.WALLKIT_API_BASE}/api/v1/integrations/users/1001/external/subscriptions/refund`, {
  method: "PUT",
  headers: {"resource": process.env.RESOURCE_KEY, "service-api-key": process.env.SERVICE_API_KEY, "Content-Type": "application/json"},
  body: JSON.stringify({"transaction": {"id": "order-reader-1001", "refund_amount": 500}})
});
console.log(response.status, await response.json());
import os, json
from urllib.request import Request, urlopen
from urllib.error import HTTPError

body = {'transaction': {'id': 'order-reader-1001', 'refund_amount': 500}}
request = Request(os.environ["WALLKIT_API_BASE"] + "/api/v1/integrations/users/1001/external/subscriptions/refund",
    data=json.dumps(body).encode("utf-8"),
    headers={'resource': os.environ["RESOURCE_KEY"], 'service-api-key': os.environ["SERVICE_API_KEY"], 'Content-Type': "application/json"}, method="PUT")
try:
    with urlopen(request) as response:
        print(response.status, json.load(response))
except HTTPError as error:
    print(error.code, json.load(error))

Synthetic intended HTTP 200 result:

{
  "success": true
}

Consequential alternate

With refund_amount:0, the route records the full transaction amount rather than zero. A second request overwrites the stored amount again; it does not add another refund amount.

An ID that exists only for a different user, different resource or nonexternal transaction returns HTTP 404:

{
  "error": "transaction_not_found",
  "error_description": "Transaction not found"
}

An error after transaction save or an inner swallowed downgrade failure does not establish unchanged membership or no recorded refund.

Recovery

HTTP statusAPI code / shapeCauseNext action
401 / 403access / auth_failed / auth_access_fail / token_expired / token_compromisedAccount action grant or resolved user/session/relationship checks failed.Ask the integration owner to check the exact account, action and resource context.
404resource_not_existsResource key does not resolve.Correct the resource context.
404 / 409user_not_found / user_id_requiredTarget absent, unrelated to resource or invalid integer ID.Confirm target ID and resource relationship.
409incorrect_dataFalsey parsed JSON.Supply an object-shaped JSON request.
404transaction_obj_requiredMissing/empty transaction after JSON extraction.Supply a nonempty transaction object; malformed scalar/array shapes have no dependable error envelope.
409transaction_id_property_required / transaction_id_required / transaction_refund_amount_property_requiredMissing/empty identifier or missing amount property.Send the external ID and intended amount; remember 0 means full.
404transaction_not_foundNo matching external transaction for this user/resource.Confirm user, resource and external ID before recording.
404 / 409subscription_not_found / subscription_source_id_requiredOptional catalog mapping cannot resolve or is empty.Correct the complete catalog token; inspect earlier transaction effects before another write.
400external_subscription_failedCaught refund, membership handling or post-commit event failure.Inspect recorded amount and membership/history before retrying; money must be reconciled with the external system separately.
200success:trueLocal path acknowledged; inner downgrade can fail silently.Inspect stored amount and resulting memberships; never infer provider money transfer.
Unspecified outward failureMalformed nested objectFailure can fall outside Exception handling.Supply proper object-shaped data and inspect uncertain outcomes.

Next task

Read external transaction lookup for recorded amount_refunded, confirming its user_id, then inspect memberships and a separate content-access decision. Reconcile actual refunded money with the external system, using its own records.

See the Server-integrations (advanced) to select the actor, identifiers and next task.

Full diagram

Use the arrow keys to scroll. Escape closes this view.

Search documentation

Enter at least 2 characters.

    ↑ ↓ move through results · Enter opens · Escape closes