Set transaction with server-side API
Creates a new transaction for an end user of your app in Adapty and provides access level. The transaction created by this method will appear in your analytics and Event Feed and well as will be sent to all integrations.
This method is recommended over the Grant access level one.
Before setting a transaction, make sure the product is created in Adapty. Without this step, the transaction will still be recorded in the Adapty database, meaning it will appear in analytics and be included in integration events. However, the user won’t get access in the mobile app since no access level will be assigned.
Method and endpoint
POST https://api.adapty.io/api/v2/server-side-api/purchase/set/transaction/
Varies based on whether the purchase is a subscription or a one-time purchase.
For subscription
Parameter | Type | Required in request | Nullable in request | Description |
---|---|---|---|---|
purchase_type | String | ➕ | ➖ | The type of product purchased. Possible value: subscription . |
store | String | ➕ | ➖ | Store where the product was bought. Options include app_store, play_store, stripe, or the name of your custom store. |
environment | String | ➖ | ➖ | Environment where the transaction took place. Options are Sandbox or Production . |
store_product_id | String | ➕ | ➖ | ID of the product in the app store (like App Store, Google Play, Stripe) that unlocked this access level. |
store_transaction_id | String | ➕ | ➖ | Transaction ID in the app store (App Store, Google Play, Stripe, etc.). |
store_original_transaction_id | String | ➕ | ➖ | For subscriptions, this ID links to the first transaction in a renewal chain. Each renewal is connected to this original transaction. If there’s no renewal, store_original_transaction_id matches store_transaction_id. |
offer | Object | ➕ | ➖ | The offer used in the purchase, provided as an Offer object. |
is_family_shared | Boolean | ➖ | ➖ | A Boolean value indicating whether the product supports family sharing in App Store Connect. iOS only. Always false for iOS below 14.0 and macOS below 11.0. |
price | Object | ➕ | ➖ | Price of the subscription or purchase as a Price object. An initial subscription purchase with zero cost is a free trial; a renewal with zero cost is a free renewal. |
purchased_at | ISO 8601 date | ➕ | ➖ | The datetime of the most recent access level purchase. |
refunded_at | ISO 8601 date | ➖ | ➖ | The datetime when the subscription was refunded, if applicable. |
cancellation_reason | String | ➕ | ➕ | Possible reasons for cancellation include: voluntarily_cancelled , billing_error , price_increase , product_was_not_available , refund , upgraded , or unknown . |
variation_id | String | ➖ | ➖ | The variation ID used to trace purchases to the specific paywall they were made from. |
originally_purchased_at | ISO 8601 date | ➕ | ➖ | For subscription chains, this is the purchase date of the original transaction, linked by store_original_transaction_id . |
expires_at | ISO 8601 date | ➕ | ➕ | The datetime when the access level expires. It may be in the past and may be null for lifetime access. |
renew_status | Boolean | ➕ | ➖ | Indicates if the subscription auto-renewal is enabled. |
renew_status_changed_at | ISO 8601 date | ➖ | ➖ | The datetime when auto-renewal when auto-renewal was either enabled or disabled. |
billing_issue_detected_at | ISO 8601 date | ➕ | ➕ | The datetime when a billing issue was detected (e.g., a failed card charge). Subscription might still be active. This is cleared if the payment goes through. |
grace_period_expires_at | ISO 8601 date | ➖ | ➖ | The datetime when the grace period will end, if the subscription is currently in one. |
For one-time purchase
Parameter | Type | Required in request | Nullable in request | Description |
---|---|---|---|---|
purchase_type | String | ➕ | ➖ | The type of product purchased. Possible value: one_time_purchase . |
store | String | ➕ | ➖ | Store where the product was bought. Possible values: app_store, play_store, stripe, or the Store ID of your custom store. |
environment | String | ➖ | ➖ | Transaction environment that provided the access level. Options: Sandbox , Production . |
store_product_id | String | ➕ | ➖ | The product ID in the app store (App Store, Google Play, Stripe, etc.) that unlocked this access level. |
store_transaction_id | String | ➕ | ➖ | Transaction ID in the app store (App Store, Google Play, Stripe, etc.). |
store_original_transaction_id | String | ➕ | ➖ | For recurring subscriptions, this is the original transaction ID that links the chain of renewals. The original transaction is the first in the chain; later transactions are extensions of it. If no extensions, store_original_transaction_id matches store_transaction_id. |
offer | Object | ➖ | ➖ | The offer used for the purchase as an Offer object. |
is_family_shared | Boolean | ➖ | ➖ | A Boolean value indicating whether the product supports family sharing in App Store Connect. iOS only. Always false for iOS below 14.0 and macOS below 11.0. |
price | Object | ➕ | ➖ | Price of the subscription or purchase as a Price object. An initial subscription purchase with zero cost is a free trial; a renewal with zero cost is a free renewal. |
purchased_at | ISO 8601 date | ➕ | ➖ | The datetime when the access level was last purchased. |
refunded_at | ISO 8601 date | ➖ | ➖ | If refunded, shows the datetime of the refund. |
cancellation_reason | String | ➖ | ➖ | Possible reasons for cancellation: voluntarily_cancelled , billing_error , price_increase , product_was_not_available , refund , cancelled_by_developer , new_subscription_replace , upgraded , unknown , adapty_revoked . |
variation_id | String | ➖ | ➖ | The variation ID used to trace purchases to the specific paywall they were made from. |
Request example
{
"purchase_type": "one_time_purchase",
"store": "app_store",
"environment": "Production",
"store_product_id": "1year.premium",
"store_transaction_id": "30002109551456",
"store_original_transaction_id": "30002109461269",
"offer": {
"category": "introductory",
"type": "free_trial",
"id": "annual_free_trial"
},
"is_family_shared": false,
"price": {
"country": "US",
"currency": "USD",
"value": 0
},
"purchased_at": "2022-10-12T09:42:50.000000+0000",
"refunded_at": "2022-10-12T09:42:50.000000+0000",
"cancellation_reason": "voluntarily_cancelled",
"variation_id": "81109d24-ea95-4806-9ec7-b482bbd1a33d"
}
Successful response
Header:
Name | Type | Description |
---|---|---|
Request-Id | String | The Request ID, which is included in all backend logs. Example: 758f01dfd9e74ccfbabb4934241c4966 . |
Body:
Successful response example
{
"data": {
"app_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"profile_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customer_user_id": "8612ED7C-3477-466D-93AE-1854B8E5FDD5",
"total_revenue_usd": 109.88999999999999,
"segment_hash": "string",
"timestamp": 0,
"custom_attributes": [
{
"key": "string",
"value": "string"
}
],
"access_levels": [
{
"access_level_id": "premium",
"store": "app_store",
"store_product_id": "weekly_8.99",
"store_base_plan_id": "",
"store_transaction_id": "530001802720333",
"store_original_transaction_id": "530001724306018",
"offer": {
"category": "introductory",
"type": "free_trial",
"id": "offer12"
},
"environment": "Production",
"starts_at": "2022-10-12T09:42:50.000000+0000",
"purchased_at": "2022-10-12T09:42:50.000000+0000",
"originally_purchased_at": "2021-10-12T09:42:50.000000+0000",
"expires_at": "2022-10-12T09:42:50.000000+0000",
"renewal_cancelled_at": "2022-10-12T09:42:50.000000+0000",
"billing_issue_detected_at": "2022-10-12T09:42:50.000000+0000",
"is_in_grace_period": true,
"cancellation_reason": "voluntarily_cancelled"
}
],
"subscriptions": [
{
"store": "app_store",
"store_product_id": "weekly_8.99",
"store_base_plan_id": "",
"store_transaction_id": "530001802720333",
"store_original_transaction_id": "530001724306018",
"offer": {
"offer_category": "introductory",
"offer_type": "free_trial",
"offer_id": "offer12"
},
"environment": "Production",
"purchased_at": "2022-10-12T09:42:50.000000+0000",
"originally_purchased_at": "2021-10-12T09:42:50.000000+0000",
"expires_at": "2022-10-12T09:42:50.000000+0000",
"renewal_cancelled_at": "2022-10-12T09:42:50.000000+0000",
"billing_issue_detected_at": "2022-10-12T09:42:50.000000+0000",
"is_in_grace_period": true,
"cancellation_reason": "voluntarily_cancelled"
}
],
"non_subscriptions": [
{
"purchase_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"store": "app_store",
"store_product_id": "weekly_8.99",
"store_base_plan_id": "",
"store_transaction_id": "530001724306018",
"store_original_transaction_id": "530001724306018",
"purchased_at": "2022-10-12T09:42:50.000000+0000",
"environment": "Production",
"is_refund": true,
"is_consumable": true
}
]
}
}
Errors
400 - Bad request
**billing_issue_detected_at_date_comparison_error
A billing issue happens when there’s a problem during a subscription renewal attempt, so it always occurs after the transaction date (purchased_at
).
To resolve this, make sure the billing issue date (billing_issue_detected_at
) is later than the transaction date (purchased_at
).
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always billing_issue_detected_at_date_comparison_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
{
"errors": [
{
"source": "billing_issue_detected_at",
"errors": [
"billing_issue_detected_at must be later than purchased_at."
]
}
],
"error_code": "billing_issue_detected_at_date_comparison_error",
"status_code": 400
}
expires_date_error
A user can’t buy a subscription that has already expired. So, the expires_at
date (when the subscription expires) should always be later than the purchased_at
date (when the transaction occurred).
To fix this, check these dates and ensure that expires_at
is after purchased_at
.
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always expires_date_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
{
"errors": [
{
"source": "expires_at",
"errors": [
"expires_at must be later than purchased_at."
]
}
],
"error_code": "expires_date_error",
"status_code": 400
}
family_share_price_error
The request failed because the is_family_shared
parameter is set to true
, meaning the access level is shared with a family member for free. However, the value
parameter of the Price object isn’t set to zero.
If is_family_shared
should be true
, make sure to set the value
parameter of the Price object to 0
.
Parameters
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always: family_share_price_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
The profile is not found
{
"errors": [
{
"source": "is_family_shared",
"errors": [
"If is_family_shared is true, price.value must be 0."
]
}
],
"error_code": "family_share_price_error",
"status_code": 400
}
free_trial_price_error
The request failed because the offer_type
parameter is set to free_trial
, but the value
parameter of the Price object isn’t set to zero.
Another possible reason is that the offer_id
parameter was included but left null
, even though it can’t be null. In this case, either provide a value for offer_id
or remove the parameter entirely.
Parameters
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always: free_trial_price_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
The profile is not found
{
"errors": [
{
"source": "offer_type",
"errors": [
"If offer_type is 'free_trial', price.value must be 0."
]
}
],
"error_code": "free_trial_price_error",
"status_code": 400
}
grace_period_billing_error
The start of a grace period counts as a billing issue. So, if the grace period has started (indicated by the grace_period_expires_at
parameter being filled in), its start date should be recorded in the billing_issue_detected_at
parameter.
To fix this, either set the start of the grace period in billing_issue_detected_at
or, if the grace period hasn’t started yet, remove the grace_period_expires_at
parameter.
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always grace_period_billing_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
{
"errors": [
{
"source": "grace_period_billing_error",
"errors": [
"If grace_period_expires_at is specified, billing_issue_detected_at must also be specified."
]
}
],
"error_code": "grace_period_billing_error",
"status_code": 400
}
grace_period_expires_date_error
The request failed because the offer_type
parameter is set to free_trial
, but the value
parameter of the Price object isn’t set to zero.
Another possible reason is that the offer_id
parameter was included but left null
, even though it can’t be null. In this case, either provide a value for offer_id
or remove the parameter entirely.
Parameters
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always: free_trial_price_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
The profile is not found
{
"errors": [
{
"source": "offer_type",
"errors": [
"If offer_type is 'free_trial', price.value must be 0."
]
}
],
"error_code": "free_trial_price_error",
"status_code": 400
}
missing_offer_id
The request failed because the offer_category
parameter has a value other than introductory
or offer_type
but doesn’t include an offer_id
. In this case, either provide an offer_id
or remove the offer_category
or offer_type
from the request.
Another possible reason is that the offer_id
parameter was added but left as null
, even though it can’t be null. If that’s the case, either add a value for offer_id
or remove the parameter entirely.
Parameters
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Possible value: missing_offer_id . |
status_code | Integer | HTTP status. Always 400 . |
Response example
The profile is not found
{
"errors": [
{
"source": "offer_category",
"errors": [
"offer_id must be specified for all offer types except 'introductory'."
]
}
],
"error_code": "missing_offer_id",
"status_code": 400
}
originally_purchased_date_error
profile_does_not_exist
The request failed because the profile in the request header wasn’t found. Double-check that there are no typos in the profile_id
or customer_user_id
you entered in the request header, and make sure it’s for the correct app.
Parameters
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Possible value: profile_does_not_exist . |
status_code | Integer | HTTP status. Always 400 . |
Response example
The profile is not found
{
"errors": [
{
"source": "non_field_errors",
"errors": [
"Profile not found"
]
}
],
"error_code": "profile_does_not_exist",
"status_code": 400
}
refund_date_error
The request failed because the purchase date (purchased_at
) is earlier than or equal to the refund date (refunded_at
). A refund always happens after a purchase, as it reverses the transaction.
To fix this, check the purchased_at
and refunded_at
parameters and make sure the refund date is later than the purchase date.
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always refund_date_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
{
"errors": [
{
"source": "refunded_at",
"errors": [
"refunded_at must be later than purchased_at."
]
}
],
"error_code": "refund_date_error",
"status_code": 400
}
refund_fields_error
The request failed because it either includes cancellation_reason
without a refunded_at
date, or it has refunded_at
without a cancellation_reason
.
When a refund is set, both the refund date and reason need to be specified.
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always refund_fields_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
{
"errors": [
{
"source": "refunded_at",
"errors": [
"refunded_at and cancellation_reason=refund must be specified together."
]
}
],
"error_code": "refund_fields_error",
"status_code": 400
}
renew_status_changed_date_error
Renewal is a prolongation of a subscription. The user can cancel the subscription prolongation and then again prolong it. The time of both these actions is stored in the renew_status_changed_at
parameter. And it can never happen earlier than the transaction itself.
To fix the issue, make sure the renew_status_changed_at
is later than the time of the transaction (purchased_at
).
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always originally_purchased_date_error . |
status_code | Integer | HTTP status. Always 400 . |
Response example
{
"errors": [
{
"source": "renew_status_changed_at",
"errors": [
"renew_status_changed_at must be later than purchased_at."
]
}
],
"error_code": "renew_status_changed_date_error",
"status_code": 400
}
store_transaction_id_error
In the case of prolonged subscriptions, a chain of subscriptions is generated. The original transaction is the very first transaction in this chain and the chain is linked by it. Other transactions in the chain are prolongations. If the transaction is the very first purchase in the subscription chain, it can be its own original transaction.
Another case is a one-time purchase. It never creates chains as it cannot have prolongations. For it, the store_transaction_id
is always the same as the store_original_transaction_id
.
Your request failed because the store_transaction_id
value for the One-Time Purchase object differs from its store_original_transaction_id
. To fix the issue, either make them the same, or change the object - use Subscription instead of the One-Time Purchase.
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always store_transaction_id_error . |
status_code | Integer | HTTP status. Always 400. |
Response example
{
"errors": [
{
"source": "store_transaction_id",
"errors": [
"store_transaction_id must be equal to store_original_transaction_id for purchase."
]
}
],
"error_code": "store_transaction_id_error",
"status_code": 400
}
401 - Unauthorized
The request failed due to missing or incorrect authorization. Check the Authorization page, paying close attention to the Authorization header.
The request also failed because the specified profile wasn’t found.
Body:
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always not_authenticated . |
status_code | Integer | HTTP status. Always 401. |
Response example:
{
"errors": [
{
"source": "non_field_errors",
"errors": [
"Authentication credentials were not provided."
]
}
],
"error_code": "not_authenticated",
"status_code": 401
}
404 - Not found
The request failed because the specified profile wasn’t found.
Body
Parameter | Type | Description |
---|---|---|
errors | Object |
|
error_code | String | Short error name. Always not_found . |
status_code | Integer | HTTP status. Always 404 . |
Response example
{
"errors": [
{
"source": "non_field_errors",
"errors": [
"Not found."
]
}
],
"error_code": "not_found",
"status_code": 404
}
See also: