Payments with PayPal IPN and Meteor

First of all, if you need to do payment with Meteor, I’d highly recommend using Stripe or Braintree. Their API is much simpler, better UX, everything … so why am I using PayPal?

Well Stripe is not in my country, and Braintree beta rejected our application because it’s a dating website!!!! *face palm*

Anyways, without much other choice, I went with the PayPal basic payment HTML form, submit to pay on their site, and return data with IPN (Instant Payment Notifications). It’s friggin stone-age technology but heck it still works.


the easiest part is to make the form:

<form id="payment-form" action="" method="post" target="_top">
    <input type="hidden" name="cmd" value="_xclick-subscriptions">
    <input type="hidden" name="business" value="A239SFDACAFWN">
    <input type="hidden" name="lc" value="HK">
    <input type="hidden" name="item_name" value="{{$.Session.get 'itemName'}}">
    <input type="hidden" name="no_note" value="1">
    <input type="hidden" name="email" value="{{emailAddress emails}}">
    <input type="hidden" name="night_phone_b" value="{{}}">
    <input type="hidden" name="address1" value="{{billing.address}}">
    <input type="hidden" name="first_name" value="{{billing.firstName}}">
    <input type="hidden" name="last_name" value="{{billing.lastName}}">
    <input type="hidden" name="item_number" value="{{_id}}">
    <input type="hidden" name="rm" value="1">
    <input type="hidden" name="return" value="{{buildAbsUrl 'star-payment-success'}}">
    <input type="hidden" name="notify_url" value="{{buildAbsUrl 'star-payment-ipn'}}">
    <input type="hidden" name="src" value="1">
    <input type="hidden" name="a3" value="{{$.Session.get 'membershipPrice'}}">
    <input type="hidden" name="p3" value="{{$.Session.get 'durationMonths'}}">
    <input type="hidden" name="t3" value="M">
    <input type="hidden" name="currency_code" value="HKD">
    <input type="hidden" name="bn" value="PP-SubscriptionsBF:btn_buynowCC_LG_wCUP.gif:NonHosted">
    <input type="image" src="" border="0" name="submit" alt="PayPal - 更安全、更簡單的網上付款方式!">
    <img alt="" border="0" src="" width="1" height="1">

Here you just gotta check the paypal developer page for the HTML payment form API, and set the fields according.

Just to point out how ancient and terrible their API, why the hell ‘night_phone_b’ ??? and a3 is…. price??? p3 ?? t3 M stands for months??? what the …

Anyways just read the damn docs and it’s there. It’s terrible but it’s there.

After submitting the form, it’ll redirect to paypal and pay. If there was no need to track payment record, that’s actually it. Simple. However it’s unlikely that you don’t want to track the payment in your database. Moving on …

Basically after the payment, if you specify the ‘notify_url’ in the HTML form, paypal will post yet-another terrible POST request to your server telling you some transaction details. You’ll then write a server-route to pick it up and do shit accordingly. Here below I give a upgrade membership to a paid user (this is a subscription payment handling. If it’s one time payment, it’s basically the same without the subscription start/cancellation part):

var handlePayment = function(req, res, verifiedCallback) {
  const ipn = Meteor.npmRequire("paypal-ipn");

  // create a wrapped version of the verify function so that we can verify synchronously
  const wrappedVerify = Async.wrap(ipn,"verify");
  let verified = false;

  // only handle POSTs
  if (req.method === "POST") {
    // PayPal expects you to immediately verify the IPN, so do that first before further processing:
    try {
      verified = wrappedVerify(req.body);
    catch (err) {
      console.log('paypalError:' + err);

    if (verified === 'VERIFIED') {

Picker.route('/star-payment-ipn', function(params, req, res, next) {
  handlePayment(req, res, function() {
    let payment = req.body;

    var userId = payment.item_number;
    var requestType = payment.txn_type;
    var itemName = payment.item_name;
    var paidAmount = Number(payment.mc_gross);
    var payerEmail = payment.payer_email;
    var txnId = payment.txn_id;

    if (requestType == 'subscr_payment') {

      // receiving payments
      if (paidAmount > 0) {

          paidAt: new Date(),
          paidAmount: paidAmount,
          payerEmail: payerEmail,
          userId: userId,
          transactionId: txnId,
          itemName: itemName

        var numMonths;
        switch (itemName) {
          case '1個月星級會藉':
            numMonths = 1;
          case '3個月星級會藉':
            numMonths = 3;
          case '6個月星級會藉':
            numMonths = 6;
          case '12個月星級會藉':
            numMonths = 12;

        // set star membership date based on their item
        var d = new Date();
        var newStarEnd = new Date(d.setMonth(d.getMonth() + numMonths));
        console.log('setting user starEnd to ' + newStarEnd);
        Meteor.users.update(userId, { $set: {starEnd: newStarEnd} });

      // set user billing object to billing to save typing next time
      var billing = {
        firstName: payment.first_name,
        lastName: payment.last_name,
        address: payment.address_street && payment.address_street.replace(/\n|\r/g, "")
      if (userId && billing.firstName && billing.lastName && billing.address)
        Meteor.users.update(userId, { $set: {billing: billing} });

    // refunds
    else if (!requestType) {
      if (paidAmount < 0) {
        console.log(`${userId} refunded for ${itemName}, refundAmount: ${paidAmount}`);
        console.log('inserting new refund record in StarPayments, transactionId: ' + txnId);

          paidAt: new Date(),
          refundAmount: -paidAmount,
          payerEmail: payerEmail,
          userId: userId,
          transactionId: txnId,
          itemName: itemName

        console.log('setting user starEnd to nothing');
        Meteor.users.update(userId, { $unset: {starEnd: ''} });

    // subscription starts
    else if (requestType == 'subscr_signup') {
      console.log(`${userId} starts subscription for ${itemName}`);
      console.log('updating user subscriptionStatus to active');
      Meteor.users.update(userId, { $set: {'subscriptionStatus': 'active'} });

    // subscription cancelled
    else if (requestType == 'subscr_cancel') {
      console.log(`${userId} cancels subscription for ${itemName}`);
      console.log('updating user subscriptionStatus to cancelled');
      Meteor.users.update(userId, { $set: {'subscriptionStatus': 'cancelled'} });



Here I use meteorhacks:picker for the server-side routing package. If you use iron-router, it comes with server-side routing and you won’t need the picker package.

You’ll also need the paypal-ipn NPM package. I used the Meteor 1.2 which requires meteorhacks:npm to use NPM package. For meteor 1.3+, the NPM package should be installed directly.


Paypal payment API is ancient and terrible. Lots of API docs reading, which will be useless information elsewhere.

Also you cannot test the IPN locally (they can’t send you the POST request to your localhost). So every little change I have to push to staging, which makes trial-and-error takes SUPER long.

All in all I spent about 8-10 hours to get this whole thing working.

As usual, it’s not the most comprehensive tutorial, hope it helps, please let me know if you need more help, you can find my contact in the homepage.

3 thoughts on “Payments with PayPal IPN and Meteor

  1. Hi lytstephen,

    Thanks for the post. I’m going through the same motions right now with Moneris. I was wondering how you’re displaying content back to the user? I’m using Meteor with Angular2 and I can’t seem to wrap my head around how after I process the payment info, display a receipt view.


    • If I remember right, paypal will post the IPN to a route that you specify. Then you use the server route method (picker) to pick up on it and read the variables, then do whatever you need.

      If you still need, find my email in the sidebar, and send me an email I’d be happy to give you my code snippets 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *