Ivy on File

Setup and use mandates to charge users multiple times

Create a mandate for recurring payments with PIS

You can embed setting up a mandate to be used for recurring charges within a one-off payment flow. This simplifies the process in your system, as it removes the need to build extra confirmation screens or integrate new Ivy endpoints.

On a high level, the flow is the following:

  1. Checkout Creation: Add a mandate object to your Checkout Create request, set the field setup to true , pass a referenceId for the mandate to be able to associate the respective mandate_setup_succeeded and mandate_setup_failed webhook events with your initial request later on. Additionally you need to pass a userNotificationEmail to the mandate object which is used to be displayed during the checkout when requesting to setup a mandate (you need to notify the user about the respective events using this email address as well). Proceed the same way as you would for a usual payment flow with Checkout Sessions and send your user to Ivy's hosted Checkout flow.

    // Recommended Configuration
    {
      "price": {
        "totalNet": 100,
        "vat": 19,
        "total": 119,
        "currency": "EUR"
      },
      "referenceId": "my-unique-checkout-reference-id",
      "successCallbackUrl": "https://my-website.com/success",
      "errorCallbackUrl": "https://my-website.com/try-again",
      "...": "...",
      "mandate": {
        "setup": true, // needs to be true to setup a mandate
        "referenceId": "my-unique-mandate-reference",
        "userNotificationEmail": "the-email-address-of-your-user-to-send-notifications-to",
        "accountHolderName": "the-account-holder-name"
      }
    }
    
  2. Within the Payment flow, users confirm to set up a mandate on the Ivy screens.

  3. Following a successful payment authorization that starts with the CheckoutSession, an Order will be created. Alongside this Order, a mandate will be set up, but it will be set up in a way that does not happen immediately but rather separately and without blocking the payment authorisation process..

Handling mandate related webhook events

Successful mandate setup

Following a successful mandate setup, you can receive the mandate_setup_succeeded webhook event to retrieve and store the mandate's id which can be use to initiate charges afterwards. This webhook event contains all relevant mandate data that can be used to inform the user about the issuance and usage of the mandate as required by the SEPA regulation.

{
  "id": "", // The id of the specific webhook event. Use this in the /webhook/trigger endpoint to re-send the webhook.
  "type": "mandate_setup_succeeded",
  "payload": {
    "id": "", // the id of the mandate to be used in the future
    "referenceId": "", // the reference provided during the initial checkout session under mandate.referenceId
    "reference": "", // the mandate reference that will appear on the customer's bank statement
    "signature": {
      "ip": "",
      "token": "", 
      "signedAt": ""
    },
    "creditor": {
      "address": {
        "street": "", 
        "city": "",
        "postalCode": "",
        "country": "" // alpha2 country code
      },
      "id": "",
      "name": ""
    },
    "debtor": {  // debtor account information of the account the mandate has been issued for
      "account": {
        "iban": "",
        "accountHolderName": "",
        "bic": ""
      }
    }
  },
  "date":"datetime" // The datetime timestamp of the webhook
}

Failed mandate setup

When a mandate setup fails, you can either get notified using the mandate_setup_failed webhook event to get more insights, why the mandate setup failed.

{
  "id": "", // The id of the specific webhook event. Use this in the /webhook/trigger endpoint to re-send the webhook.
  "type": "mandate_setup_failed",
  "payload": {
    "error": {
      "code": "",
      "message": ""
    },
    "referenceId": "", // the reference provided during the initial checkout session under mandate.referenceId
  },
  "date":"datetime" // The datetime timestamp of the webhook
}

Mandate revocation

When a charge is reversed by a customer or we detected that it's no longer valid due to any other reason, we inform you about the mandate being revoked. In this case this mandate can't be used any longer to create a charge and a new mandate needs to be setup in case you want to create further charges for the respective customer.

{
  "id": "", // The id of the specific webhook event. Use this in the /webhook/trigger endpoint to re-send the webhook.
  "type": "mandate_revoked",
  "payload": {
    "id": "", // the id of the mandate
    "referenceId": "", // the reference provided during the initial checkout session under mandate.referenceId
    "revokedAt": "" // the date time the mandate has been revoked
  },
  "date":"datetime" // The datetime timestamp of the webhook
}

Example Webhook Handler

const express = require('express')
const router = express.Router()
const { createHmac } = require('crypto')
const config = require('./config') // assuming your config is in a file named config.js

function sign(data, secret) {
  const hmac = createHmac('sha256', secret)
  hmac.update(JSON.stringify(data))
  return hmac.digest('hex')
}

router.post('/webhooks/ivy/mandate', (req, res) => {
  const data = req.body
  
  // verify signature
  const secret = config.IVY_WEBHOOK_SIGNING_SECRET
  const expectedSignature = sign(data, secret)
  const signature = req.get('X-Ivy-Signature')

  if (signature !== expectedSignature) {
    throw new Error('Invalid signature')
  }

  // check for type of event
  switch (data.type) {
    case 'mandate_setup_succeeded':
      // take data.payload.referenceId to identify the original request
      // use / store data.payload.id for further usage of the mandate
      break;
    case 'mandate_setup_failed':
      // take data.payload.referenceId to identify the original request
      // if appropriate e.g. let the customer know that they should try again or not
      // you can for example adjust your communication based on 
      // data.payload.error.code
      // and
      // data.payload.error.message
      break;
    case 'mandate_revoked':
      // take data.id and mark this mandate as not being usable anymore
      // you might want / need to inform your customer and ask the next time for giving consent
      // to set up a new mandate
      break;
    default:
      // handle unknown status
      throw new Error('Unknown mandate event type')
  }
  
  res.send({ success: true })
})

module.exports = router
<?php
// Assuming you have a config.php file with your configurations
require_once 'config.php';

// Function to sign data
function sign($data, $secret) {
    $hmac = hash_hmac('sha256', json_encode($data), $secret);
    return $hmac;
}

// Check if the request is a POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Get JSON payload from the request body
    $json = file_get_contents('php://input');
    $data = json_decode($json, true);

    // Verify signature
    $secret = IVY_WEBHOOK_SIGNING_SECRET; // Defined in config.php
    $expectedSignature = sign($data, $secret);
    $signature = $_SERVER['HTTP_X_IVY_SIGNATURE'] ?? '';

    if ($signature !== $expectedSignature) {
        throw new Exception('Invalid signature');
    }

    // check for type of event
    switch ($data['type']) {
        case 'mandate_setup_succeeded':
          // take data.payload.referenceId to identify the original request
          // use / store data.payload.id for further usage of the mandate
          break;
        case 'mandate_setup_failed':
          // take data.payload.referenceId to identify the original request
          // if appropriate e.g. let the customer know that they should try again or not
          // you can for example adjust your communication based on 
          // data.payload.error.code
          // and
          // data.payload.error.message
          break;
        case 'mandate_revoked':
          // take data.id and mark this mandate as not being usable anymore
          // you might want / need to inform your customer and ask the next time for giving consent
          // to set up a new mandate
          break;
        default:
          // handle unknown status
          throw new Exception('Unknown mandate event type')
    }

    // Send success response
    header('Content-Type: application/json');
    echo json_encode(['success' => true]);
} else {
    // Handle non-POST requests here
    header('HTTP/1.1 405 Method Not Allowed');
    exit;
}
?>

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
use App\Service\SignatureVerifier; // Assuming you have a service for signature verification

class WebhookController extends AbstractController
{
    private $signatureVerifier;

    /**
     * @Required
     */
    public function setSignatureVerifier(SignatureVerifier $signatureVerifier): void
    {
        $this->signatureVerifier = $signatureVerifier;
    }

    /**
     * @Route("/webhooks/ivy/mandate", name="ivy_mandate_webhook", methods={"POST"})
     */
    public function ivyWebhook(Request $request): Response
    {
        $data = json_decode($request->getContent(), true);

        // Verify signature
        $signature = $request->headers->get('X-Ivy-Signature');
        if (!$this->signatureVerifier->verifySignature($data, $signature)) {
            throw new HttpException(400, 'Invalid signature');
        }

        // check for type of event
        switch ($data['type']) {
            case 'mandate_setup_succeeded':
              // take data.payload.referenceId to identify the original request
              // use / store data.payload.id for further usage of the mandate
              break;
            case 'mandate_setup_failed':
              // take data.payload.referenceId to identify the original request
              // if appropriate e.g. let the customer know that they should try again or not
              // you can for example adjust your communication based on 
              // data.payload.error.code
              // and
              // data.payload.error.message
              break;
            case 'mandate_revoked':
              // take data.id and mark this mandate as not being usable anymore
              // you might want / need to inform your customer and ask the next time for giving consent
              // to set up a new mandate
              break;
            default:
              // handle unknown status
              throw new HttpException(400, 'Unknown mandate event type')
        }

        return new Response(json_encode(['success' => true]), 200, ['Content-Type' => 'application/json']);
    }
}

from fastapi import FastAPI, Request, HTTPException
import hashlib
import hmac
import json

app = FastAPI()

# Assuming you have a configuration module or environment variables
# For example, using environment variables:
import os
IVY_WEBHOOK_SIGNING_SECRET = os.environ.get("IVY_WEBHOOK_SIGNING_SECRET")

def verify_signature(data: str, signature: str, secret: str) -> bool:
    """Verify HMAC signature."""
    hmac_signature = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(hmac_signature, signature)

@app.post("/webhooks/ivy/mandate")
async def ivy_webhook(request: Request):
    body = await request.body()
    body_str = body.decode("utf-8")

    # Retrieve the signature from the headers
    signature = request.headers.get("x-ivy-signature")

    # Verify signature
    if not verify_signature(body_str, signature, IVY_WEBHOOK_SIGNING_SECRET):
        raise HTTPException(status_code=400, detail="Invalid signature")

    data = json.loads(body_str)

    # Check event type
    type = data.get("type")
    if type not in ["mandate_setup_succeeded", "mandate_setup_failed", "mandate_revoked"]:
        raise HTTPException(status_code=400, detail="Unknown mandate event type")

    # Handle the statuses here
    # For example, you can log them or perform actions based on the status
    print(f"Mandate status: {status}")

    return {"success": True}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Charge a user using a mandate

With a valid mandate id, you can charge users without any user interaction. This simplifies charging customers for e.g. one-click user experiences or if you want to trigger recurring payments.

The following steps are necessary:

  1. Create a new Charge with the /charge/create endpoint. Provide the mandate's mandateId you previously received (as explained in the preceding paragraph), the price defined by total, currency and a unique referenceId for the charge you can use to identify later on when receiving webhooks . An idempotencyKey is mandatory for this call to prevent you from accidentally creating multiple charges for a single charge intent.
    This way, the associated bank account is charged.
    curl -X POST "https://api.sand.getivy.de/api/service/charge/create" \
         -H "Content-Type: application/json" \
         -H "X-Ivy-Api-Key: {Your Ivy Api Key}" \
         -d '{
               "mandateId": "provide_mandate_id_here"
               "price": {
               	"total": 100.23
                	"currency": "EUR"
                },
               "idempotencyKey": "a_key_to_uniquely_identify_your_request", 
               "referenceId": "unique_reference_id"
             }'
    
  2. Case 1: the Charge request failed
    1. A Validation API Error with the reason for the failure is returned by the Charge Create endpoint
  3. Case 2: the Charge request succeeded
    1. A charge to the user's bank account has been submitted and is being processed.
    2. A corresponding Order object is created with status: processing, retrievable with the Order API. A order_created webhook is sent.

Handle Status Updates

Ivy uses the Order object to keep track of the payment status and informs you of any creation and update by sending order_created and order_updated webhooks.

As the charge to the bankToken is in most cases a Direct Debit payment, it can take up to 14 business days to receive notification on the success or failure of a charge. However, the average is five business days.

Caseorder.statusWhat to do
Submitted PaymentprocessingNothing
Successful PaymentpaidFulfill the order / service
Failed PaymentfailedCancel the order / service
Disputed PaymentdisputedCancel the order / service

Example Webhook Handler

const express = require('express')
const router = express.Router()
const { createHmac } = require('crypto')
const config = require('./config') // assuming your config is in a file named config.js

function sign(data, secret) {
  const hmac = createHmac('sha256', secret)
  hmac.update(JSON.stringify(data))
  return hmac.digest('hex')
}

router.post('/webhooks/ivy', (req, res) => {
  const data = req.body
  
  // verify signature
  const secret = config.IVY_WEBHOOK_SIGNING_SECRET
  const expectedSignature = sign(data, secret)
  const signature = req.get('X-Ivy-Signature')

  if (signature !== expectedSignature) {
    throw new Error('Invalid signature')
  }
  
  // check if correct event
  if (data.type !== "order_created" && data.type !== "order_updated") {
    throw new Error('Expected different Webhook Event')
  }

  // check order.status
  switch (data.payload.status) {
    case 'processing':
      // do nothing 
      break;
    case 'paid':
      // fulfill the order / service
      break;
    case 'failed':
      // cancel the order / service
      break;
    case 'disputed':
      // cancel the order / service
    default:
      // handle unknown status
      throw new Error('Unknown status')
  }
  
  res.send({ success: true })
})

module.exports = router
<?php
// Assuming you have a config.php file with your configurations
require_once 'config.php';

// Function to sign data
function sign($data, $secret) {
    $hmac = hash_hmac('sha256', json_encode($data), $secret);
    return $hmac;
}

// Check if the request is a POST request
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Get JSON payload from the request body
    $json = file_get_contents('php://input');
    $data = json_decode($json, true);

    // Verify signature
    $secret = IVY_WEBHOOK_SIGNING_SECRET; // Defined in config.php
    $expectedSignature = sign($data, $secret);
    $signature = $_SERVER['HTTP_X_IVY_SIGNATURE'] ?? '';

    if ($signature !== $expectedSignature) {
        throw new Exception('Invalid signature');
    }

    // Check if correct event
    if ($data['type'] !== "order_created" && $data['type'] !== "order_updated") {
        throw new Exception('Expected different Webhook Event');
    }

    // Check order.status
    switch ($data['payload']['status']) {
        case 'processing':
            // Do nothing
            break;
        case 'paid':
            // Fulfill the order/service
            break;
        case 'failed':
            // Cancel the order/service
            break;
        case 'disputed':
            // Handle dispute
            break;
        default:
            // Handle unknown status
            throw new Exception('Unknown status');
    }

    // Send success response
    header('Content-Type: application/json');
    echo json_encode(['success' => true]);
} else {
    // Handle non-POST requests here
    header('HTTP/1.1 405 Method Not Allowed');
    exit;
}
?>

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\Service\Attribute\Required;
use App\Service\SignatureVerifier; // Assuming you have a service for signature verification

class WebhookController extends AbstractController
{
    private $signatureVerifier;

    /**
     * @Required
     */
    public function setSignatureVerifier(SignatureVerifier $signatureVerifier): void
    {
        $this->signatureVerifier = $signatureVerifier;
    }

    /**
     * @Route("/webhooks/ivy", name="ivy_webhook", methods={"POST"})
     */
    public function ivyWebhook(Request $request): Response
    {
        $data = json_decode($request->getContent(), true);

        // Verify signature
        $signature = $request->headers->get('X-Ivy-Signature');
        if (!$this->signatureVerifier->verifySignature($data, $signature)) {
            throw new HttpException(400, 'Invalid signature');
        }

        // Check if correct event
        if ($data['type'] !== "order_created" && $data['type'] !== "order_updated") {
            throw new HttpException(400, 'Expected different Webhook Event');
        }

        // Check order.status
        switch ($data['payload']['status']) {
            case 'processing':
                // Do nothing
                break;
            case 'paid':
                // Fulfill the order/service
                break;
            case 'failed':
                // Cancel the order/service
                break;
            case 'disputed':
                // Handle dispute
                break;
            default:
                // Handle unknown status
                throw new HttpException(400, 'Unknown status');
        }

        return new Response(json_encode(['success' => true]), 200, ['Content-Type' => 'application/json']);
    }
}

from fastapi import FastAPI, Request, HTTPException
import hashlib
import hmac
import json

app = FastAPI()

# Assuming you have a configuration module or environment variables
# For example, using environment variables:
import os
IVY_WEBHOOK_SIGNING_SECRET = os.environ.get("IVY_WEBHOOK_SIGNING_SECRET")

def verify_signature(data: str, signature: str, secret: str) -> bool:
    """Verify HMAC signature."""
    hmac_signature = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(hmac_signature, signature)

@app.post("/webhooks/ivy")
async def ivy_webhook(request: Request):
    body = await request.body()
    body_str = body.decode("utf-8")

    # Retrieve the signature from the headers
    signature = request.headers.get("x-ivy-signature")

    # Verify signature
    if not verify_signature(body_str, signature, IVY_WEBHOOK_SIGNING_SECRET):
        raise HTTPException(status_code=400, detail="Invalid signature")

    data = json.loads(body_str)

    # Check if correct event
    if data.get("type") not in ["order_created", "order_updated"]:
        raise HTTPException(status_code=400, detail="Expected different Webhook Event")

    # Check order.status
    status = data.get("payload", {}).get("status")
    if status not in ["processing", "paid", "failed", "disputed"]:
        raise HTTPException(status_code=400, detail="Unknown status")

    # Handle the statuses here
    # For example, you can log them or perform actions based on the status
    print(f"Order status: {status}")

    return {"success": True}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Disputed Payments

Direct Debit provides a dispute process for customers to dispute payments.

Customers can dispute a payment through their bank on a “no questions asked” basis up to eight weeks after their account is debited. Any disputes within this period are automatically honored.

After eight weeks and up to 13 months, a customer can only dispute a payment with their bank if the debit is considered unauthorized. If this occurs, Ivy automatically provides the bank with the mandate that the customer approved. This does not guarantee cancellation of the dispute; the bank can still decide that the debit was unauthorized and the customer is entitled to a refund.

A dispute can also occur if the bank is unable to debit the customer’s account because of an issue (for example, the account is frozen or has insufficient funds), but has already provided the funds to make the charge successful. If this occurs, the bank reclaims the funds in the form of a dispute.

Disputes are final and there is no process for appeal. If a user successfully disputes a payment, you must contact them if you want to resolve the situation. If you’re able to come to an arrangement and your user is willing to return the funds to you, they must make a new payment.

In case of a dispute, the order.status changes to disputed, a order_updated webhook is sent and the amount of the initial payment + a dispute fee is being deducted from the Ivy settlement account.


Simulating Failed and Disputed Payments in Sandbox

Simulating failure

Failing charges can happen for several reasons as e.g. a closed bank account, processing issues on the bank side etc. These are expected to happen before the payment is actually settled. In order to test this scenario you can use our sandbox environment using a referenceId ending with -fail (e.g. abcdefg123456-fail):

POST /api/service/charge/create

{
  "mandateId": "<the mandateid you received with the mandate_setup_succeed webhook event>",
  "referenceId": "any string you like ending with -fail",
  "idempotencyKey: "any string you want to use",
  "price": {
  	"total": 1,
  	"currency": "EUR"
  }
}

This will first accept the charge, giving you an order_created webhook event. After a short while you will receive an order_updated webhook event, indicating that the order failed.

Simulating dispute / chargeback

Disputed charges can happen after a payment has been settled as they represent as result of a customer triggered chargeback. The consequence of this is, that the order will be marked as disputed and the mandate will be revoked.

In order to test this scenario you can use our sandbox environment using a referenceId ending with -dispute (e.g. abcdefg123456-dispute):

POST /api/service/charge/create

{
  "mandateId": "<the mandateid you received with the mandate_setup_succeed webhook event>",
  "referenceId": "any string you like ending with -dispute",
  "idempotencyKey: "any string you want to use",
  "price": {
  	"total": 1,
  	"currency": "EUR"
  }
}

This will first accept the charge, giving you an order_created webhook event. After a short while you will receive an order_updated webhook event, indicating that the order has been paid. After another short while you will receive an order_updated event, indicating that the order has been disputed and also you will receive a mandate_revoked webhook event, indicating that the mandate has been revoked and should not be used any more (in fact you won't be able to use it for any further charges in this case)