Couldn't purchase with Subscription offer

I ran into the same issue while testing out the new WWDC2019 example Node.js server files they provided. After following the readme, I was able to successfully generate a signature.

To my surprise, however, an invalid signature will look just like a valid one, and it took me a while to realize that my signature was invalid.

My error was the following: I used Alamofire to make a GET request to my server, like so:

AF.request("myserver:3000/offer", parameters: parameters).responseJSON { response in

        var signature: String?
        var keyID: String?
        var timestamp: NSNumber?
        var nonce: UUID?

        switch response.result {
        case let .success(value):
            let json = JSON(value)

            // Get required parameters for creating offer
            signature = json["signature"].stringValue
            keyID = json["keyID"].stringValue
            timestamp = json["timestamp"].numberValue
            nonce = UUID(uuidString: json["nonce"].stringValue)

        case let .failure(error):
            print(error)
            return
        }

        // Create offer
        let discountOffer = SKPaymentDiscount(identifier: offerIdentifier, keyIdentifier: keyID!, nonce: nonce!, signature: signature!, timestamp: timestamp!)

        // Pass offer in completion block
        completion(discountOffer) // this completion is a part of the method where this snippet is running
    }
}

On the files provided in the WWDC2019 Video on Subscription Offers, in the index.js file, they are loading the parameters I passed on my request like so:

const appBundleID = req.body.appBundleID;
const productIdentifier = req.body.productIdentifier;
const subscriptionOfferID = req.body.offerID;
const applicationUsername = req.body.applicationUsername;

However, my alamofire request did not pass the parameters in the body, but rather, as query parameters. Therefore, the server was generating a signature with a null appBundleID as well as other null fields! So I changed the aforementioned section of index.js to the following:

const appBundleID = req.query.appBundleID;
const productIdentifier = req.query.productIdentifier;
const subscriptionOfferID = req.query.offerID;
const applicationUsername = req.query.applicationUsername;

I hope this helps anyone who overlooked this. Pardon my unsafe swift, but I hope you get the point!


After many trials and errors, figured the issue. Basically it was because of the wrong algorithm and along with minor issues here and there. Here is the complete code in Node.js, hope this helps someone.

  // https://developer.apple.com/documentation/storekit/in-app_purchase/generating_a_signature_for_subscription_offers
  // Step 1
  const appBundleID = req.body.appBundleID
  const keyIdentifier = req.body.keyIdentifier
  const productIdentifier = req.body.productIdentifier
  const offerIdentifier = req.body.offerIdentifier
  const applicationUsername = req.body.applicationUsername

  const nonce = uuid4()
  const timestamp = Math.floor(new Date())

  // Step 2
  // Combine the parameters into a UTF-8 string with 
  // an invisible separator ('\u2063') between them, 
  // in the order shown:
  // appBundleId + '\u2063' + keyIdentifier + '\u2063' + productIdentifier + 
  // '\u2063' + offerIdentifier + '\u2063' + applicationUsername + '\u2063' + 
  // nonce + '\u2063' + timestamp

  let payload = appBundleID + '\u2063' + keyIdentifier + '\u2063' + productIdentifier + '\u2063' + offerIdentifier + '\u2063' + applicationUsername + '\u2063' + nonce+ '\u2063' + timestamp

  // Step 3
  // Sign the combined string
  // Private Key - p8 file downloaded
  // Algorithm - ECDSA with SHA-256

  const keyPem = fs.readFileSync('file_name.pem', 'ascii');
  // Even though we are specifying "RSA" here, this works with ECDSA
  // keys as well.
  // Step 4
  // Base64-encode the binary signature
  const sign = crypto.createSign('RSA-SHA256')
                   .update(payload)
                   .sign(keyPem, 'base64');

  let response1 = {
    "signature": sign,
    "nonce": nonce,
    "timestamp": timestamp,
    "keyIdentifier": keyIdentifier
  }
  res.type('json').send(response1);