├── .github ├── static │ └── .gitignore └── workflows │ └── go.yml ├── .gitignore ├── go.mod ├── go.sum ├── license.md ├── readme.md ├── types.go ├── v2.go └── v2_test.go /.github/static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/izniburak/appstore-notifications-go/b2c21bd3348fc4a011e2bdfaa67655a71030c32a/.github/static/.gitignore -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.20' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | env: 30 | APPLE_NOTIFICATION_REQUEST: '${{ vars.APPLE_NOTIFICATION_REQUEST }}' 31 | APPLE_CERT: '${{ vars.APPLE_CERT }}' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | mock/ 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/izniburak/appstore-notifications-go 2 | 3 | go 1.18 4 | 5 | require github.com/golang-jwt/jwt v3.2.2+incompatible 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 2 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 3 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023, İzni Burak Demirtaş 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # App Store Server Notifications in Golang [![](https://github.com/izniburak/appstore-notifications-go/actions/workflows/go.yml/badge.svg)](https://github.com/izniburak/appstore-notifications-go/actions) [![PkgGoDev](https://pkg.go.dev/badge/github.com/izniburak/appstore-notifications-go)](https://pkg.go.dev/github.com/izniburak/appstore-notifications-go) 2 | 3 | ***appstore-notifications-go*** is a Golang package designed to assist in handling, verifying, and parsing the [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications), which is a webhook service corresponds to Apple's App Store events related to your apps. 4 | 5 | > App Store Server Notifications is a service provided by Apple for its App Store. It's designed to notify developers about key events and changes related to their app's in-app purchases and subscriptions. By integrating this service into their server-side logic, developers can receive real-time (I think, almost real-time) updates about various events without having to repeatedly poll the Apple servers. 6 | 7 | ## Install 8 | To install the package, you can use following command on your terminal in your project directory: 9 | 10 | ```bash 11 | go get github.com/izniburak/appstore-notifications-go 12 | ``` 13 | 14 | ## Examples 15 | ```go 16 | package main 17 | 18 | import ( 19 | "encoding/json" 20 | "fmt" 21 | "strings" 22 | appstore "github.com/izniburak/appstore-notifications-go" 23 | ) 24 | 25 | func main() { 26 | // App Store Server Notification Request JSON String 27 | appStoreServerRequest := "..." // {"signedPayload":"..."} 28 | var request appstore.AppStoreServerRequest 29 | err := json.Unmarshal([]byte(appStoreServerRequest), &request) // bind byte to header structure 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | // Apple Root CA - G3 Root certificate 35 | // for details: https://www.apple.com/certificateauthority/ 36 | // you need download it and covert it to a valid pem file in order to verify X5c certificates 37 | // `openssl x509 -in AppleRootCA-G3.cer -out cert.pem` 38 | rootCert := "-----BEGIN CERTIFICATE----- ......" 39 | if rootCert == "" { 40 | panic("Apple Root Cert not valid") 41 | } 42 | 43 | appStoreServerNotification := appstore.New(request.SignedPayload, rootCert) 44 | fmt.Printf("App Store Server Notification is valid?: %t\n", appStoreServerNotification.IsValid) 45 | fmt.Printf("Product Id: %s\n", appStoreServerNotification.TransactionInfo.ProductId) 46 | } 47 | ``` 48 | You can access the all data in the payload by using one of the 4 params in instance of the `AppStoreServerNotification`: 49 | 50 | - _instance_***.Payload***: Access the [Payload](https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload). 51 | - _instance_***.TransactionInfo***: Access the [Transaction Info](https://developer.apple.com/documentation/appstoreservernotifications/jwstransactiondecodedpayload). 52 | - _instance_***.RenewalInfo***: Access the [Renewal Info](https://developer.apple.com/documentation/appstoreservernotifications/jwsrenewalinfodecodedpayload). 53 | - _instance_***.IsValid***: Check the payload parsed and verified successfully. 54 | 55 | ## Contributing 56 | 57 | 1. Fork [this repo](https://github.com/izniburak/appstore-notifications-go/fork) 58 | 2. Create your feature branch (git checkout -b my-new-feature) 59 | 3. Commit your changes (git commit -am 'Add some feature') 60 | 4. Push to the branch (git push origin my-new-feature) 61 | 5. Create a new Pull Request 62 | 63 | ## Contributors 64 | 65 | - [izniburak](https://github.com/izniburak) İzni Burak Demirtaş - creator, maintainer 66 | 67 | ## License 68 | The MIT License (MIT) - see [`license.md`](https://github.com//izniburak/appstore-notifications-go/blob/main/license.md) for more details 69 | 70 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import "github.com/golang-jwt/jwt" 4 | 5 | type AppStoreServerNotification struct { 6 | appleRootCert string 7 | Payload *NotificationPayload 8 | TransactionInfo *TransactionInfo 9 | RenewalInfo *RenewalInfo 10 | IsValid bool 11 | IsTest bool 12 | } 13 | 14 | type AppStoreServerRequest struct { 15 | SignedPayload string `json:"signedPayload"` 16 | } 17 | 18 | type NotificationHeader struct { 19 | Alg string `json:"alg"` 20 | X5c []string `json:"x5c"` 21 | } 22 | 23 | type NotificationPayload struct { 24 | jwt.StandardClaims 25 | NotificationType string `json:"notificationType"` 26 | Subtype string `json:"subtype"` 27 | NotificationUUID string `json:"notificationUUID"` 28 | Version string `json:"version"` 29 | SignedDate int `json:"signedDate"` 30 | Summary NotificationSummary `json:"summary,omitempty"` 31 | Data NotificationData `json:"data,omitempty"` 32 | ExternalPurchaseToken ExternalPurchaseToken `json:"externalPurchaseToken,omitempty"` 33 | } 34 | 35 | type ExternalPurchaseToken struct { 36 | ExternalPurchaseId string `json:"externalPurchaseId"` 37 | TokenCreationDate int `json:"tokenCreationDate"` 38 | AppAppleId string `json:"appAppleId"` 39 | BundleId string `json:"bundleId"` 40 | } 41 | 42 | type NotificationSummary struct { 43 | RequestIdentifier string `json:"requestIdentifier"` 44 | AppAppleId string `json:"appAppleId"` 45 | BundleId string `json:"bundleId"` 46 | ProductId string `json:"productId"` 47 | Environment string `json:"environment"` 48 | StoreFrontCountryCodes []string `json:"storefrontCountryCodes"` 49 | FailedCount int64 `json:"failedCount"` 50 | SucceededCount int64 `json:"succeededCount"` 51 | } 52 | 53 | type NotificationData struct { 54 | AppAppleId int `json:"appAppleId"` 55 | BundleId string `json:"bundleId"` 56 | BundleVersion string `json:"bundleVersion"` 57 | Environment string `json:"environment"` 58 | SignedRenewalInfo string `json:"signedRenewalInfo"` 59 | SignedTransactionInfo string `json:"signedTransactionInfo"` 60 | Status int32 `json:"status"` 61 | ConsumptionRequestReason string `json:"consumptionRequestReason,omitempty"` 62 | } 63 | 64 | type TransactionInfo struct { 65 | jwt.StandardClaims 66 | AppAccountToken string `json:"appAccountToken"` 67 | BundleId string `json:"bundleId"` 68 | Currency string `json:"currency,omitempty"` 69 | Environment string `json:"environment"` 70 | ExpiresDate int `json:"expiresDate"` 71 | InAppOwnershipType string `json:"inAppOwnershipType"` 72 | IsUpgraded bool `json:"isUpgraded"` 73 | OfferDiscountType string `json:"offerDiscountType"` 74 | OfferIdentifier string `json:"offerIdentifier"` 75 | OfferType int32 `json:"offerType"` 76 | OriginalPurchaseDate int `json:"originalPurchaseDate"` 77 | OriginalTransactionId string `json:"originalTransactionId"` 78 | Price int64 `json:"price,omitempty"` 79 | ProductId string `json:"productId"` 80 | PurchaseDate int `json:"purchaseDate"` 81 | Quantity int32 `json:"quantity"` 82 | RevocationDate int `json:"revocationDate"` 83 | RevocationReason int32 `json:"revocationReason"` 84 | SignedDate int `json:"signedDate"` 85 | StoreFront string `json:"storefront"` 86 | StoreFrontId string `json:"storefrontId"` 87 | SubscriptionGroupIdentifier string `json:"subscriptionGroupIdentifier"` 88 | TransactionId string `json:"transactionId"` 89 | TransactionReason string `json:"transactionReason"` 90 | Type string `json:"type"` 91 | WebOrderLineItemId string `json:"webOrderLineItemId"` 92 | } 93 | 94 | type RenewalInfo struct { 95 | jwt.StandardClaims 96 | AutoRenewProductId string `json:"autoRenewProductId"` 97 | AutoRenewStatus int32 `json:"autoRenewStatus"` 98 | Environment string `json:"environment"` 99 | Currency string `json:"currency"` 100 | EligibleWinBackOfferIds string `json:"eligibleWinBackOfferIds"` 101 | ExpirationIntent int32 `json:"expirationIntent"` 102 | GracePeriodExpiresDate int `json:"gracePeriodExpiresDate"` 103 | IsInBillingRetryPeriod bool `json:"isInBillingRetryPeriod"` 104 | OfferDiscountType string `json:"offerDiscountType"` 105 | OfferIdentifier string `json:"offerIdentifier"` 106 | OfferType int32 `json:"offerType"` 107 | OriginalTransactionId string `json:"originalTransactionId"` 108 | PriceIncreaseStatus int32 `json:"priceIncreaseStatus"` 109 | ProductId string `json:"productId"` 110 | RecentSubscriptionStartDate int `json:"recentSubscriptionStartDate"` 111 | RenewalDate int `json:"renewalDate"` 112 | RenewalPrice int `json:"renewalPrice"` 113 | SignedDate int `json:"signedDate"` 114 | } 115 | -------------------------------------------------------------------------------- /v2.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/json" 8 | "errors" 9 | "strings" 10 | 11 | "github.com/golang-jwt/jwt" 12 | ) 13 | 14 | func New(payload string, appleRootCert string) *AppStoreServerNotification { 15 | asn := &AppStoreServerNotification{} 16 | asn.IsValid = false 17 | asn.IsTest = false 18 | asn.appleRootCert = appleRootCert 19 | asn.parseJwtSignedPayload(payload) 20 | return asn 21 | } 22 | 23 | func (asn *AppStoreServerNotification) extractHeaderByIndex(payload string, index int) ([]byte, error) { 24 | // get header from token 25 | payloadArr := strings.Split(payload, ".") 26 | 27 | // convert header to byte 28 | headerByte, err := base64.RawStdEncoding.DecodeString(payloadArr[0]) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | // bind byte to header structure 34 | var header NotificationHeader 35 | err = json.Unmarshal(headerByte, &header) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | // decode x.509 certificate headers to byte 41 | certByte, err := base64.StdEncoding.DecodeString(header.X5c[index]) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return certByte, nil 47 | } 48 | 49 | func (asn *AppStoreServerNotification) verifyCertificate(certByte []byte, intermediateCert []byte) error { 50 | // create certificate pool 51 | roots := x509.NewCertPool() 52 | 53 | // parse and append apple root certificate to the pool 54 | ok := roots.AppendCertsFromPEM([]byte(asn.appleRootCert)) 55 | if !ok { 56 | return errors.New("root certificate couldn't be parsed") 57 | } 58 | 59 | // parse and append intermediate x5c certificate 60 | interCert, err := x509.ParseCertificate(intermediateCert) 61 | if err != nil { 62 | return errors.New("intermediate certificate couldn't be parsed") 63 | } 64 | intermediate := x509.NewCertPool() 65 | intermediate.AddCert(interCert) 66 | 67 | // parse x5c certificate 68 | cert, err := x509.ParseCertificate(certByte) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | // verify X5c certificate using app store certificate resides in opts 74 | opts := x509.VerifyOptions{ 75 | Roots: roots, 76 | Intermediates: intermediate, 77 | } 78 | if _, err := cert.Verify(opts); err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (asn *AppStoreServerNotification) extractPublicKeyFromPayload(payload string) (*ecdsa.PublicKey, error) { 86 | // get certificate from X5c[0] header 87 | certStr, err := asn.extractHeaderByIndex(payload, 0) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | // parse certificate 93 | cert, err := x509.ParseCertificate(certStr) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | // get public key 99 | switch pk := cert.PublicKey.(type) { 100 | case *ecdsa.PublicKey: 101 | return pk, nil 102 | default: 103 | return nil, errors.New("appstore public key must be of type ecdsa.PublicKey") 104 | } 105 | } 106 | 107 | func (asn *AppStoreServerNotification) parseJwtSignedPayload(payload string) { 108 | // get root certificate from x5c header 109 | rootCertStr, err := asn.extractHeaderByIndex(payload, 2) 110 | if err != nil { 111 | panic(err) 112 | } 113 | 114 | // get intermediate certificate from x5c header 115 | intermediateCertStr, err := asn.extractHeaderByIndex(payload, 1) 116 | if err != nil { 117 | panic(err) 118 | } 119 | 120 | // verify certificates 121 | if err = asn.verifyCertificate(rootCertStr, intermediateCertStr); err != nil { 122 | panic(err) 123 | } 124 | 125 | // payload data 126 | notificationPayload := &NotificationPayload{} 127 | _, err = jwt.ParseWithClaims(payload, notificationPayload, func(token *jwt.Token) (interface{}, error) { 128 | return asn.extractPublicKeyFromPayload(payload) 129 | }) 130 | if err != nil { 131 | panic(err) 132 | } 133 | asn.Payload = notificationPayload 134 | asn.IsTest = asn.Payload.NotificationType == "TEST" 135 | 136 | if asn.IsTest { 137 | asn.IsValid = true 138 | return 139 | } 140 | 141 | // transaction info 142 | transactionInfo := &TransactionInfo{} 143 | payload = asn.Payload.Data.SignedTransactionInfo 144 | _, err = jwt.ParseWithClaims(payload, transactionInfo, func(token *jwt.Token) (interface{}, error) { 145 | return asn.extractPublicKeyFromPayload(payload) 146 | }) 147 | if err != nil { 148 | panic(err) 149 | } 150 | asn.TransactionInfo = transactionInfo 151 | 152 | // renewal info 153 | renewalInfo := &RenewalInfo{} 154 | payload = asn.Payload.Data.SignedRenewalInfo 155 | _, err = jwt.ParseWithClaims(payload, renewalInfo, func(token *jwt.Token) (interface{}, error) { 156 | return asn.extractPublicKeyFromPayload(payload) 157 | }) 158 | if err != nil { 159 | panic(err) 160 | } 161 | asn.RenewalInfo = renewalInfo 162 | 163 | // valid request 164 | asn.IsValid = true 165 | } 166 | -------------------------------------------------------------------------------- /v2_test.go: -------------------------------------------------------------------------------- 1 | package v2 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestServerNotificationV2(t *testing.T) { 11 | // {"signedPayload":"..."} 12 | appStoreServerRequest := os.Getenv("APPLE_NOTIFICATION_REQUEST") 13 | if appStoreServerRequest == "" { 14 | panic("No valid AppStoreServerRequest") 15 | } 16 | var request AppStoreServerRequest 17 | err := json.Unmarshal([]byte(appStoreServerRequest), &request) // bind byte to header structure 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | // -----BEGIN CERTIFICATE----- ...... 23 | rootCert := os.Getenv("APPLE_CERT") 24 | if rootCert == "" { 25 | panic("Apple Root Cert not available") 26 | } 27 | 28 | appStoreServerNotification := New(request.SignedPayload, rootCert) 29 | 30 | if !appStoreServerNotification.IsValid { 31 | t.Error("Payload is not valid") 32 | } 33 | 34 | fmt.Printf( 35 | "NotificationType: %s\nEnvironment: %s\nIsTest: %t\n", 36 | appStoreServerNotification.Payload.NotificationType, 37 | appStoreServerNotification.Payload.Data.Environment, 38 | appStoreServerNotification.IsTest, 39 | ) 40 | } 41 | --------------------------------------------------------------------------------